use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::mnote_agent_tools::ToolCallInput; use axum::extract::{Extension, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; use std::time::Instant; use tracing::info; pub async fn block_edit_workflow( State(state): State, Extension(context): Extension, Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let started = Instant::now(); let workspace_id = string_field(&payload, "workspaceId") .or_else(|| context.workspace.workspace_id.clone()) .ok_or_else(|| { WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 workspaceId") .with_context(&context) })?; let document_id = string_field(&payload, "documentId").ok_or_else(|| { WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 documentId") .with_context(&context) })?; let message = string_field(&payload, "message").ok_or_else(|| { WebError::bad_request_code("page_ai_workflow_bad_request", "缺少 message") .with_context(&context) })?; let trace_id = string_field(&payload, "traceId").unwrap_or_else(|| context.trace.trace_id.clone()); let session_id = string_field(&payload, "sessionId") .unwrap_or_else(|| format!("page_ai_fast_{}", context.trace.request_id)); let run_id = string_field(&payload, "runId").unwrap_or_else(|| session_id.clone()); info!( trace_id = %trace_id, run_id = %run_id, workspace_id = %workspace_id, document_id = %document_id, "mnote page AI block workflow started" ); if !looks_like_block_edit(&message) { return Err(WebError::bad_request_code( "page_ai_workflow_not_block_edit", "当前请求不像块编辑任务,交给通用页面 AI", ) .with_context(&context)); } let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null); let ai_context = page_context .get("aiContext") .cloned() .or_else(|| payload.get("aiContext").cloned()) .ok_or_else(|| { WebError::bad_request_code( "page_ai_workflow_missing_context", "块编辑快路径缺少 mnote.page_ai_context.v1", ) .with_context(&context) })?; let profile = string_field(&payload, "profile").unwrap_or_else(|| "mnoteai".into()); let model_started = Instant::now(); // 退役 direct_block_edit_operations:不再走正则抠「」的本地快路径。 // 所有块编辑请求统一走模型 → search/replace 对 → doc_markdown_edit。 let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?; let markdown_plan = extract_markdown_plan_from_model_text(&model_output)?; let markdown_operations = markdown_plan.operations.clone(); info!( trace_id = %trace_id, run_id = %run_id, operations = markdown_operations.len(), model_ms = model_started.elapsed().as_millis(), "mnote page AI workflow model completed" ); if markdown_operations.is_empty() { return Err(WebError::bad_request_code( "page_ai_workflow_empty_operations", "模型未返回搜索替换操作", ) .with_context(&context)); } // Fail-closed: unauthenticated actors cannot write via the page-ai workflow. // Dev fixtures may use dev_user_id only when explicitly enabled. let actor_id = { let raw = context.auth.actor_id.trim(); if raw.is_empty() || raw == "anonymous" { if state.config().allow_dev_fixtures { state.config().dev_user_id.clone() } else { return Err(WebError::new( StatusCode::UNAUTHORIZED, "page_ai_workflow_auth_required", "页面 AI 工作流需要先登录", ) .with_context(&context)); } } 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::>() }) .unwrap_or_default(); let mut markdown_args = json!({ "operations": markdown_operations.clone(), // 写入守卫 fail-closed:快路径也必须显式授予写权限。 "aiAccessScope": { "permissionLevel": "read_write", "allowedResourceIds": [document_id.clone()], "allowedTargetBlockIds": allowed_target_block_ids.clone(), }, "commandContext": { "ai.canWrite": true, "workspace.readonly": false } }); 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()), document_id: Some(document_id.clone()), source_kind: None, root_uri: None, actor_id: Some(actor_id), profile: Some(profile), session_id: Some(session_id), run_id: Some(run_id.clone()), tool_call_id: Some(format!("fast_edit_{}", context.trace.request_id)), trace_id: Some(trace_id.clone()), 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(markdown_args), }; let apply_started = Instant::now(); let tool_response = crate::routes::mnote_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, run_id = %run_id, apply_ms = apply_ms, total_ms = started.elapsed().as_millis(), "mnote page AI block workflow completed" ); Ok(( StatusCode::OK, HeaderMap::new(), Json(json!({ "ok": true, "schema": "mnote.page_ai_block_edit_workflow.v1", "fastPath": true, "documentId": document_id, "workspaceId": workspace_id, "runId": run_id, "traceId": trace_id, "operations": markdown_operations, "applyResult": apply_result, "toolExecution": tool_response, "message": markdown_plan .summary .unwrap_or_else(|| "已通过页面 markdown 编辑快路径完成写入。".into()), "timingsMs": { "total": started.elapsed().as_millis(), "apply": apply_ms } })), )) } struct MarkdownEditPlan { operations: Vec, summary: Option, } /// 模型输出可能把 JSON 再包一层 `choices[0].message.content` 字符串;限制解包深度防栈溢出。 const MODEL_JSON_UNWRAP_MAX_DEPTH: u8 = 4; fn extract_markdown_plan_from_model_text(text: &str) -> Result { extract_markdown_plan_from_model_text_depth(text, 0) } fn extract_markdown_plan_from_model_text_depth( text: &str, depth: u8, ) -> Result { if depth > MODEL_JSON_UNWRAP_MAX_DEPTH { return Err(WebError::bad_request_code( "page_ai_workflow_model_output_too_nested", "模型输出嵌套过深,拒绝解析", )); } let parsed = parse_model_json(text)?; if let Some(content) = parsed .pointer("/choices/0/message/content") .and_then(Value::as_str) { return extract_markdown_plan_from_model_text_depth(content, depth + 1); } let summary = parsed .get("summary") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); if let Some(operations) = parsed.get("operations").and_then(Value::as_array) { // 新格式:直接是 search/replace 对 if operations .iter() .any(|op| op.get("search").is_some() || op.get("replace").is_some()) { return Ok(MarkdownEditPlan { operations: operations.clone(), summary, }); } // 旧格式(block ops):转换为 search/replace 对 let converted: Vec = operations .iter() .filter_map(|op| { let op_type = op.get("op").and_then(Value::as_str).unwrap_or(""); match op_type { "replace" => { let match_text = op.get("matchText").or_else(|| op.get("search")).and_then(Value::as_str)?; let content = op.get("content").or_else(|| op.get("replace")).and_then(Value::as_str)?; Some(json!({"search": match_text, "replace": content})) } "insert_after" => { let anchor = op.get("anchorText").or_else(|| op.get("matchText")).and_then(Value::as_str)?; let content = op.get("content").or_else(|| op.get("replace")).and_then(Value::as_str)?; let anchor_md = format!("{}\n\n", anchor); Some(json!({"search": anchor_md, "replace": format!("{}\n\n{}\n\n", anchor, content)})) } "delete" => { let match_text = op.get("matchText").and_then(Value::as_str)?; Some(json!({"search": match_text, "replace": ""})) } _ => None, } }) .collect(); if !converted.is_empty() { return Ok(MarkdownEditPlan { operations: converted, summary, }); } } Err(WebError::bad_request_code( "page_ai_workflow_bad_model_output", "模型输出未包含 search/replace operations", )) } #[allow(dead_code)] fn extract_operations_from_model_text(text: &str) -> Result, WebError> { extract_operations_from_model_text_depth(text, 0) } #[allow(dead_code)] fn extract_operations_from_model_text_depth( text: &str, depth: u8, ) -> Result, WebError> { if depth > MODEL_JSON_UNWRAP_MAX_DEPTH { return Err(WebError::bad_request_code( "page_ai_workflow_model_output_too_nested", "模型输出嵌套过深,拒绝解析", )); } let parsed = parse_model_json(text)?; if let Some(content) = parsed .pointer("/choices/0/message/content") .and_then(Value::as_str) { return extract_operations_from_model_text_depth(content, depth + 1); } if let Some(operations) = parsed.get("operations").and_then(Value::as_array) { return Ok(operations.clone()); } if let Some(operations) = parsed .get("arguments") .and_then(|value| value.get("operations")) .and_then(Value::as_array) { return Ok(operations.clone()); } if let Some(operations) = parsed.as_array() { return Ok(operations.clone()); } Err(WebError::bad_request_code( "page_ai_workflow_bad_model_output", "模型输出未包含 operations", )) } fn parse_model_json(text: &str) -> Result { let trimmed = strip_code_fence(text.trim()); if let Ok(value) = serde_json::from_str::(&trimmed) { return Ok(value); } if let Some(slice) = first_json_slice(&trimmed) { if let Ok(value) = serde_json::from_str::(slice) { return Ok(value); } } Err(WebError::bad_request_code( "page_ai_workflow_bad_model_json", "模型输出不是可解析 JSON", )) } fn strip_code_fence(text: &str) -> String { let trimmed = text.trim(); if !trimmed.starts_with("```") { return trimmed.to_string(); } let without_open = trimmed.lines().skip(1).collect::>().join("\n"); without_open .trim() .strip_suffix("```") .unwrap_or(without_open.trim()) .trim() .to_string() } fn first_json_slice(text: &str) -> Option<&str> { let start = text.find('{').or_else(|| text.find('['))?; let open = text.as_bytes()[start] as char; let close = if open == '{' { '}' } else { ']' }; let mut depth = 0usize; let mut in_string = false; let mut escaped = false; for (offset, ch) in text[start..].char_indices() { if in_string { if escaped { escaped = false; } else if ch == '\\' { escaped = true; } else if ch == '"' { in_string = false; } continue; } if ch == '"' { in_string = true; } else if ch == open { depth += 1; } else if ch == close { depth = depth.saturating_sub(1); if depth == 0 { return Some(&text[start..start + offset + ch.len_utf8()]); } } } None } async fn call_block_edit_model( context: &RequestContext, profile: &str, message: &str, ai_context: &Value, ) -> Result { let model = workflow_model_config(profile); let page_xml = ai_context .get("pageXml") .and_then(Value::as_str) .unwrap_or_default(); let page_text = ai_context .get("pageText") .and_then(Value::as_str) .unwrap_or_default(); let _allowed_block_ids = ai_context .get("allowedTargetBlockIds") .cloned() .unwrap_or_else(|| json!([])); let body = json!({ "model": model.model, "temperature": 0, "max_tokens": 900, "response_format": {"type": "json_object"}, "messages": [ { "role": "system", "content": "你是 mnote 页面编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。summary 要简短回答用户的读取/检查要求和写入结果;如果用户要求读取某段,summary 必须包含你从 page_text 读取到的原文。禁止输出解释文字。\n\n示例:用户说\"检查第一段并把第二段改成测试123\",若 page_text 第一段是\"第一段\",则输出:{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}" }, { "role": "user", "content": format!( "用户指令:{}\n\npage_xml(含 block id 参考):\n{}\n\npage_text(用于 search 精确复制):\n{}", message, page_xml, page_text ) } ] }); let url = format!("{}/chat/completions", model.base_url.trim_end_matches('/')); let response = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(20)) .build() .map_err(|error| { WebError::internal(format!("页面 AI workflow HTTP client 构造失败: {error}")) })? .post(url) .bearer_auth(model.api_key) .json(&body) .send() .await .map_err(|error| { WebError::bad_gateway_code( "page_ai_workflow_model_unavailable", format!("页面 AI workflow 模型请求失败: {error}"), ) .with_context(context) })?; let status = response.status(); let text = response.text().await.map_err(|error| { WebError::bad_gateway_code( "page_ai_workflow_model_body_read_failed", format!("页面 AI workflow 模型响应体读取失败: {error}"), ) .with_context(context) })?; if !status.is_success() { return Err(WebError::bad_gateway_code( "page_ai_workflow_model_failed", format!("页面 AI workflow 模型返回失败: {status}"), ) .with_context(context)); } let payload = parse_model_json(&text)?; payload .pointer("/choices/0/message/content") .and_then(Value::as_str) .map(ToOwned::to_owned) .ok_or_else(|| { WebError::bad_gateway_code( "page_ai_workflow_model_no_content", "页面 AI workflow 模型响应缺少 message.content", ) .with_context(context) }) } struct WorkflowModelConfig { model: String, base_url: String, api_key: String, } fn workflow_model_config(profile: &str) -> WorkflowModelConfig { let config = fs::read_to_string(profile_config_path(profile)).unwrap_or_default(); let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_else(|| "deepseek".into()); let model = yaml_path_value(&config, &["model", "default"]) .or_else(|| yaml_path_value(&config, &["providers", &provider, "model"])) .unwrap_or_else(|| "deepseek-v4-flash".into()); let base_url = yaml_path_value(&config, &["model", "base_url"]) .or_else(|| yaml_path_value(&config, &["providers", &provider, "base_url"])) .unwrap_or_else(|| "https://api.deepseek.com/v1".into()); let api_key = yaml_path_value(&config, &["model", "api_key"]) .or_else(|| yaml_path_value(&config, &["providers", &provider, "api_key"])) .or_else(|| { yaml_path_value(&config, &["model", "key_env"]) .or_else(|| yaml_path_value(&config, &["providers", &provider, "key_env"])) .and_then(|env_key| std::env::var(env_key).ok()) }) .unwrap_or_default(); WorkflowModelConfig { model, base_url, api_key, } } fn string_field(payload: &Value, key: &str) -> Option { payload .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn looks_like_block_edit(message: &str) -> bool { [ "新增", "添加", "插入", "删除", "删掉", "修改", "替换", "改成", "移动", "移到", "move", "replace", "delete", "insert", ] .iter() .any(|needle| message.contains(needle)) } #[allow(dead_code)] fn direct_block_edit_operations(message: &str) -> Option> { let mut operations = Vec::new(); for clause in message .split(|ch| matches!(ch, ';' | ';' | '\n')) .map(str::trim) .filter(|value| !value.is_empty()) { let quoted = quoted_segments(clause); if (clause.contains("替换") || clause.contains("改成")) && quoted.len() >= 2 { operations.push(json!({ "op": "replace", "matchText": quoted[0], "content": quoted[1] })); } else if (clause.contains("插入") || clause.contains("新增") || clause.contains("添加")) && quoted.len() >= 2 { operations.push(json!({ "op": "insert_after", "matchText": quoted[0], "content": quoted[1] })); } else if (clause.contains("删除") || clause.contains("删掉")) && !quoted.is_empty() { operations.push(json!({ "op": "delete", "matchText": quoted[0] })); } } if operations.is_empty() { None } else { Some(operations) } } #[allow(dead_code)] fn quoted_segments(value: &str) -> Vec { let mut segments = Vec::new(); let mut start: Option = None; let mut current = String::new(); for ch in value.chars() { match (start, ch) { (None, '「' | '“' | '"') => { start = Some(ch); current.clear(); } (Some('「'), '」') | (Some('“'), '”') | (Some('"'), '"') => { if !current.trim().is_empty() { segments.push(current.trim().to_string()); } current.clear(); start = None; } (Some(_), _) => current.push(ch), (None, _) => {} } } segments } fn agent_profile_home() -> PathBuf { std::env::var("MNOTE_AGENT_HOME") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) .or_else(|| { std::env::var("HERMES_HOME") .ok() .filter(|value| !value.trim().is_empty()) .map(PathBuf::from) }) .or_else(|| { std::env::var("HOME").ok().and_then(|home| { let preferred = PathBuf::from(&home).join(".mnote-agent"); if preferred.exists() { return Some(preferred); } let legacy = PathBuf::from(&home).join(".hermes"); if legacy.exists() { return Some(legacy); } Some(preferred) }) }) .unwrap_or_else(|| PathBuf::from(".mnote-agent")) } /// profile 名仅允许安全文件名字符,拒绝 `..` / 路径分隔,防止 profiles join 穿越。 fn sanitize_workflow_profile_name(profile: &str) -> String { let trimmed = profile.trim(); if trimmed.is_empty() { return "default".to_string(); } let mut out = String::with_capacity(trimmed.len().min(64)); for ch in trimmed.chars().take(64) { if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { out.push(ch); } else { out.push('_'); } } if out.is_empty() || out == "." || out == ".." || out.contains("..") { return "default".to_string(); } out } fn profile_config_path(profile: &str) -> PathBuf { let home = agent_profile_home(); let profile = profile.trim(); if profile.is_empty() || profile == "default" { return home.join("config.yaml"); } let safe = sanitize_workflow_profile_name(profile); if safe == "default" { return home.join("config.yaml"); } let profiles_root = home.join("profiles"); let candidate = profiles_root.join(&safe); // 防御:消毒后仍须落在 profiles 目录内(不解析 symlink,仅词法检查)。 if candidate .components() .any(|c| matches!(c, std::path::Component::ParentDir)) { return home.join("config.yaml"); } // 禁止跟随 profiles 下指向外部的 symlink(exists/is_dir 会跟随)。 if candidate.is_symlink() { return home.join("config.yaml"); } if candidate.is_dir() { candidate.join("config.yaml") } else { home.join("config.yaml") } } fn yaml_path_value(content: &str, path: &[&str]) -> Option { let mut stack: Vec<(usize, String)> = Vec::new(); for raw_line in content.lines() { let line = raw_line.trim_end_matches('\r'); let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') || !line.contains(':') { continue; } let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); while stack .last() .map(|(level, _)| *level >= indent) .unwrap_or(false) { stack.pop(); } let Some((key, value)) = trimmed.split_once(':') else { continue; }; let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); let value = value .trim() .trim_matches('"') .trim_matches('\'') .to_string(); stack.push((indent, key)); if stack.len() == path.len() && stack .iter() .zip(path.iter()) .all(|((_, key), expected)| key == expected) && !value.is_empty() { return Some(value); } } None } #[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::agent_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, enable_page_ai_pi_lab: false, 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(), environment: "dev".into(), })) } async fn spawn_mock_model_server() -> String { async fn completions() -> Json { 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 { 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}") } async fn spawn_read_and_edit_mock_model_server() -> String { async fn completions() -> Json { Json(json!({ "choices": [{ "message": { "content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}" } }] })) } let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind read-and-edit 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() { let operations = extract_operations_from_model_text( r#"```json {"operations":[{"op":"replace","matchText":"旧文本","content":"新文本"}],"summary":"ok"} ```"#, ) .expect("operations"); assert_eq!(operations.len(), 1); assert_eq!(operations[0]["op"], "replace"); assert_eq!(operations[0]["matchText"], "旧文本"); } #[test] fn parses_direct_chinese_block_operations() { let operations = direct_block_edit_operations( "把「第二段」替换为「第二段已修改」;在「第一段」后插入「插入段」;删除「第三段」。只简短回复结果。", ) .expect("operations"); assert_eq!(operations.len(), 3); assert_eq!(operations[0]["op"], "replace"); assert_eq!(operations[0]["matchText"], "第二段"); assert_eq!(operations[0]["content"], "第二段已修改"); assert_eq!(operations[1]["op"], "insert_after"); assert_eq!(operations[1]["matchText"], "第一段"); assert_eq!(operations[1]["content"], "插入段"); 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 agent_home = std::env::temp_dir().join(format!( "mnote-page-ai-workflow-disabled-tool-{}", std::process::id() )); let _ = fs::remove_dir_all(&agent_home); let profile_dir = agent_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("MNOTE_AGENT_HOME", &agent_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": "第一段第二段", "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("MNOTE_AGENT_HOME"); let _ = fs::remove_dir_all(&agent_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 agent_home = std::env::temp_dir().join(format!( "mnote-page-ai-workflow-selection-scope-{}", std::process::id() )); let _ = fs::remove_dir_all(&agent_home); let profile_dir = agent_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("MNOTE_AGENT_HOME", &agent_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": "第一段第二段", "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("MNOTE_AGENT_HOME"); let _ = fs::remove_dir_all(&agent_home); } #[tokio::test] async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() { let _guard = env_lock().lock().expect("env lock"); let base_url = spawn_read_and_edit_mock_model_server().await; let agent_home = std::env::temp_dir().join(format!( "mnote-page-ai-workflow-read-summary-{}", std::process::id() )); let _ = fs::remove_dir_all(&agent_home); let profile_dir = agent_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("MNOTE_AGENT_HOME", &agent_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_read_summary", "runId": "run_page_ai_read_summary", "traceId": "trace_page_ai_read_summary", "pageContext": { "aiContext": { "schema": "mnote.page_ai_context.v1", "pageText": "第一段\n\n第二段", "pageXml": "第一段第二段", "contextBlocks": [ {"blockId": "p_1", "text": "第一段"}, {"blockId": "p_2", "text": "第二段"} ] } } }) .to_string(), )) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); 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["ok"], true); assert!(payload["message"] .as_str() .unwrap_or_default() .contains("已读取第一段:第一段")); assert!(payload["message"] .as_str() .unwrap_or_default() .contains("测试123")); std::env::remove_var("MNOTE_AGENT_HOME"); let _ = fs::remove_dir_all(&agent_home); } #[test] fn profile_config_path_rejects_traversal() { use super::{profile_config_path, sanitize_workflow_profile_name}; use std::path::Component; assert_eq!(sanitize_workflow_profile_name("../../etc"), "default"); assert_eq!(sanitize_workflow_profile_name("mnoteai"), "mnoteai"); assert_eq!(sanitize_workflow_profile_name("a/b"), "a_b"); let path = profile_config_path("../../etc/passwd"); assert!( path.components().all(|c| !matches!(c, Component::ParentDir)), "profile path must not contain ParentDir: {path:?}" ); // 危险 profile 回落到 home/config.yaml,不得 join 原始 ../../ assert!( path.ends_with("config.yaml"), "expected config.yaml fallback, got {path:?}" ); let path2 = profile_config_path("mnoteai"); // may or may not exist; must be under profiles/mnoteai or home config assert!( path2.components().all(|c| !matches!(c, Component::ParentDir)) ); } #[test] fn extract_markdown_plan_rejects_deeply_nested_content() { use super::extract_markdown_plan_from_model_text; // 5 层 choices.content 嵌套 → 超过 MODEL_JSON_UNWRAP_MAX_DEPTH(4) let mut nested = r#"{"operations":[{"search":"a","replace":"b"}]}"#.to_string(); for _ in 0..5 { nested = format!( r#"{{"choices":[{{"message":{{"content":{}}}}}]}}"#, serde_json::to_string(&nested).expect("escape") ); } let err = match extract_markdown_plan_from_model_text(&nested) { Ok(_) => panic!("must reject deeply nested model JSON"), Err(e) => e, }; let msg = err.message(); assert!( msg.contains("嵌套") || msg.contains("too_nested") || format!("{err:?}").contains("too_nested") || format!("{err:?}").contains("嵌套"), "unexpected err: {err:?}" ); } }