- 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
968 lines
36 KiB
Rust
968 lines
36 KiB
Rust
use crate::app::AppState;
|
||
use crate::context::RequestContext;
|
||
use crate::error::WebError;
|
||
use crate::hermes_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));
|
||
}
|
||
let actor_id =
|
||
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
|
||
state.config().dev_user_id.clone()
|
||
} 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()),
|
||
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::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,
|
||
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>,
|
||
}
|
||
|
||
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_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(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> {
|
||
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(content);
|
||
}
|
||
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.unwrap_or_default();
|
||
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 hermes_home() -> PathBuf {
|
||
std::env::var("HERMES_HOME")
|
||
.ok()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.map(PathBuf::from)
|
||
.or_else(|| {
|
||
std::env::var("HOME")
|
||
.ok()
|
||
.map(|home| PathBuf::from(home).join(".hermes"))
|
||
})
|
||
.unwrap_or_else(|| PathBuf::from(".hermes"))
|
||
}
|
||
|
||
fn profile_config_path(profile: &str) -> PathBuf {
|
||
let home = hermes_home();
|
||
let profile = profile.trim();
|
||
if profile.is_empty() || profile == "default" {
|
||
return home.join("config.yaml");
|
||
}
|
||
let candidate = home.join("profiles").join(profile);
|
||
if candidate.exists() {
|
||
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::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}")
|
||
}
|
||
|
||
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 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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|