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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user