chore: 保存当前架构收口与 bug 修复快照

归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
lix-2026
2026-05-18 17:01:35 +08:00
parent 61ee4a38a2
commit a2cb1338c8
953 changed files with 14383 additions and 211845 deletions
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use crate::hermes_tools::ToolCallInput;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
@@ -88,6 +88,30 @@ pub async fn block_edit_workflow(
} 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()
});
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()),
@@ -101,12 +125,12 @@ pub async fn block_edit_workflow(
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(json!({
"operations": markdown_operations
})),
args: Some(markdown_args),
};
let apply_started = Instant::now();
let apply_result = doc::doc_markdown_edit(&state, &context, &edit_input).await?;
let tool_response =
crate::routes::hermes_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,
@@ -128,6 +152,7 @@ pub async fn block_edit_workflow(
"traceId": trace_id,
"operations": markdown_operations,
"applyResult": apply_result,
"toolExecution": tool_response,
"message": "已通过页面 markdown 编辑快路径完成写入。",
"timingsMs": {
"total": started.elapsed().as_millis(),
@@ -540,6 +565,124 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
#[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::hermes_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,
hermes_base_path: "/api/hermes".into(),
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(),
}))
}
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}")
}
#[test]
fn extracts_operations_from_fenced_model_json() {
@@ -570,4 +713,137 @@ mod tests {
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 hermes_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-disabled-tool-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_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("HERMES_HOME", &hermes_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("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_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 hermes_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-selection-scope-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_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("HERMES_HOME", &hermes_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("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
}