feat: advance local-first workspace checklist

- add admin access-policy UI and local access control surfaces

- add local markdown conflict resolution UI and smoke coverage

- add ACP local agent changed-files audit scaffold and read-only write guard

- document current P0-P2 checklist progress and verification evidence
This commit is contained in:
lix-2026
2026-05-19 08:07:17 +08:00
parent a2cb1338c8
commit 68d321e297
36 changed files with 8643 additions and 235 deletions
@@ -67,7 +67,8 @@ pub async fn block_edit_workflow(
// 退役 direct_block_edit_operations:不再走正则抠「」的本地快路径。
// 所有块编辑请求统一走模型 → search/replace 对 → doc_markdown_edit。
let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?;
let markdown_operations = extract_markdown_operations_from_model_text(&model_output)?;
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,
@@ -116,6 +117,8 @@ pub async fn block_edit_workflow(
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),
@@ -153,7 +156,9 @@ pub async fn block_edit_workflow(
"operations": markdown_operations,
"applyResult": apply_result,
"toolExecution": tool_response,
"message": "已通过页面 markdown 编辑快路径完成写入。",
"message": markdown_plan
.summary
.unwrap_or_else(|| "已通过页面 markdown 编辑快路径完成写入。".into()),
"timingsMs": {
"total": started.elapsed().as_millis(),
"apply": apply_ms
@@ -162,21 +167,35 @@ pub async fn block_edit_workflow(
))
}
fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
struct MarkdownEditPlan {
operations: Vec<Value>,
summary: Option<String>,
}
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_markdown_operations_from_model_text(content);
return extract_markdown_plan_from_model_text(content);
}
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(operations.clone());
return Ok(MarkdownEditPlan {
operations: operations.clone(),
summary,
});
}
// 旧格式(block ops):转换为 search/replace 对
let converted: Vec<Value> = operations
@@ -204,7 +223,10 @@ fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>,
})
.collect();
if !converted.is_empty() {
return Ok(converted);
return Ok(MarkdownEditPlan {
operations: converted,
summary,
});
}
}
Err(WebError::bad_request_code(
@@ -330,7 +352,7 @@ async fn call_block_edit_model(
"messages": [
{
"role": "system",
"content": "你是 mnote 页面编辑 workflow。只输出 JSON{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。禁止输出解释文字。\n\n示例:用户说\"第一段改成你好\",若 page_text 第一段是\"旧内容\",则输出:{\"operations\":[{\"search\":\"旧内容\",\"replace\":\"你好\"}],\"summary\":\"替换了第一段\"}"
"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",
@@ -684,6 +706,28 @@ mod tests {
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(
@@ -846,4 +890,78 @@ mod tests {
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_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 hermes_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-read-summary-{}",
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_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("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
}
}