Files
mnote/rust/crates/mnote-web/src/routes/page_ai_workflow.rs
T

1130 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppState>,
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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::<Vec<_>>()
})
.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<Value>,
summary: Option<String>,
}
/// 模型输出可能把 JSON 再包一层 `choices[0].message.content` 字符串;限制解包深度防栈溢出。
const MODEL_JSON_UNWRAP_MAX_DEPTH: u8 = 4;
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
extract_markdown_plan_from_model_text_depth(text, 0)
}
fn extract_markdown_plan_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<MarkdownEditPlan, 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_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<Value> = 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<Vec<Value>, WebError> {
extract_operations_from_model_text_depth(text, 0)
}
#[allow(dead_code)]
fn extract_operations_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<Vec<Value>, 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<Value, WebError> {
let trimmed = strip_code_fence(text.trim());
if let Ok(value) = serde_json::from_str::<Value>(&trimmed) {
return Ok(value);
}
if let Some(slice) = first_json_slice(&trimmed) {
if let Ok(value) = serde_json::from_str::<Value>(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::<Vec<_>>().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<String, WebError> {
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<String> {
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<Vec<Value>> {
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<String> {
let mut segments = Vec::new();
let mut start: Option<char> = 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 下指向外部的 symlinkexists/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<String> {
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<Value> {
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<Value> {
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<Value> {
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": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
"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": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
"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": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
"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:?}"
);
}
}