feat: EditorRuntimeActor - 三层缓存/delta/事件架构
Phase A — EditorRuntimeActor 内存缓存层 - 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init - block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径 - editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启) - bridge-runtime 三个核心函数公开化 - rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞) Phase B — 编辑器增量 delta channel - BlockDelta/DeltaOperation 类型 + actor.build_block_delta() - leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch - DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent - 工具响应含 blockDelta 字段供前端消费 Phase C — 事件 stream delta - broadcast channel 在 AppState/actor/SSE 三层贯通 - tree_events SSE 端点发 block.delta 事件 - 旧客户端降级兼容 环境修复 - rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出) - run-convex-deploy.js(封装 Convex function 部署到本地后端 3210) ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -289,6 +289,13 @@ mod tests {
|
||||
content: serde_json::json!([]),
|
||||
revision: serde_json::json!(7),
|
||||
conflict_detection_key: serde_json::json!("page_1:7"),
|
||||
block_document: serde_json::json!({
|
||||
"documentId": "page_1",
|
||||
"rootBlockIds": [],
|
||||
"blocks": []
|
||||
}),
|
||||
block_projection_version: 1,
|
||||
projection_source: "fixture".into(),
|
||||
},
|
||||
tree: page_aggregate::PageTree {
|
||||
page_subtree: serde_json::json!({"rootNodeId": "page_1"}),
|
||||
|
||||
@@ -118,12 +118,28 @@ impl Default for PageOptions {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PageOptions;
|
||||
use super::{PageBody, PageOptions};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn page_options_default_disables_heading_numbers() {
|
||||
assert!(!PageOptions::default().show_heading_numbers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_body_accepts_legacy_payload_without_block_projection() {
|
||||
let body: PageBody = serde_json::from_value(json!({
|
||||
"content": [],
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7"
|
||||
}))
|
||||
.expect("legacy page body should deserialize");
|
||||
|
||||
assert_eq!(body.revision, json!(7));
|
||||
assert_eq!(body.block_projection_version, 0);
|
||||
assert_eq!(body.block_document, json!(null));
|
||||
assert_eq!(body.projection_source, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -132,6 +148,12 @@ pub struct PageBody {
|
||||
pub content: Value,
|
||||
pub revision: Value,
|
||||
pub conflict_detection_key: Value,
|
||||
#[serde(default)]
|
||||
pub block_document: Value,
|
||||
#[serde(default)]
|
||||
pub block_projection_version: u32,
|
||||
#[serde(default)]
|
||||
pub projection_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::editor_actor::EditorRuntimeActor;
|
||||
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||
use crate::middleware::request_context::inject_request_context;
|
||||
use crate::routes::build_router;
|
||||
@@ -16,6 +17,7 @@ pub struct AppConfig {
|
||||
pub legacy_next_base_url: Option<String>,
|
||||
pub enable_legacy_next_compat: bool,
|
||||
pub enable_debug_shell_routes: bool,
|
||||
pub enable_editor_actor: bool,
|
||||
pub hermes_base_path: String,
|
||||
pub compat_next_base_path: String,
|
||||
pub convex_url: Option<String>,
|
||||
@@ -48,6 +50,7 @@ impl AppConfig {
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false),
|
||||
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
|
||||
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
|
||||
.unwrap_or_else(|_| "/api/hermes".into()),
|
||||
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
||||
@@ -130,17 +133,26 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
config: Arc<AppConfig>,
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry,
|
||||
pub editor_actor: EditorRuntimeActor,
|
||||
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
let (block_delta_tx, _) = broadcast::channel(256);
|
||||
let actor = EditorRuntimeActor::new();
|
||||
actor.set_block_delta_tx(block_delta_tx.clone());
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(),
|
||||
editor_actor: actor,
|
||||
block_delta_tx,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! EditorRuntimeActor - 页面块编辑运行时缓存层
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 持有 per-document `EditorBlockDocument` 内存态
|
||||
//! - 接收 EditorCommand,在内存中 apply,产生 diff
|
||||
//! - Convex 持久化仍由 block.rs 通过 execute_page_body_save 完成
|
||||
//!
|
||||
//! 不是 agent runtime:不维护会话、不做意图解析、不调模型(遵从 7-12 禁止项)。
|
||||
|
||||
use bridge_runtime::{
|
||||
apply_editor_command_to_document, editor_document_from_legacy_content,
|
||||
legacy_content_from_editor_document,
|
||||
};
|
||||
use core_protocol::{
|
||||
ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
|
||||
EditorCommand,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::error::WebError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorDocumentState {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document: EditorBlockDocument,
|
||||
pub revision: u64,
|
||||
pub conflict_detection_key: String,
|
||||
pub last_applied_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyResult {
|
||||
pub ok: bool,
|
||||
pub command: String,
|
||||
pub document_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub new_revision: u64,
|
||||
pub conflict_detection_key: String,
|
||||
pub changed_blocks: Vec<ChangedBlock>,
|
||||
pub warnings: Vec<Value>,
|
||||
pub blocked: bool,
|
||||
pub risk: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChangedBlock {
|
||||
pub block_id: String,
|
||||
pub op: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub before: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub after: Option<String>,
|
||||
}
|
||||
|
||||
/// 编辑器增量 delta,可直接序列化为 JSON 传给 Tiptap bridge
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockDelta {
|
||||
pub document_id: String,
|
||||
pub revision: u64,
|
||||
pub conflict_detection_key: String,
|
||||
pub operations: Vec<DeltaOperation>,
|
||||
}
|
||||
|
||||
/// 一条增量操作,编辑器可通过 blockId 定位 + chain API 执行
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "op")]
|
||||
pub enum DeltaOperation {
|
||||
#[serde(rename = "replace")]
|
||||
ReplaceBlock {
|
||||
block_id: String,
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
block_type: Option<String>,
|
||||
},
|
||||
#[serde(rename = "insert_after")]
|
||||
InsertBlockAfter {
|
||||
anchor_block_id: String,
|
||||
block_id: String,
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
block_type: Option<String>,
|
||||
},
|
||||
#[serde(rename = "delete")]
|
||||
DeleteBlock {
|
||||
block_id: String,
|
||||
},
|
||||
#[serde(rename = "move_after")]
|
||||
MoveBlock {
|
||||
block_id: String,
|
||||
anchor_block_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorRuntimeActor {
|
||||
documents: Arc<RwLock<HashMap<String, EditorDocumentState>>>,
|
||||
block_delta_tx: Arc<RwLock<Option<tokio::sync::broadcast::Sender<Value>>>>,
|
||||
}
|
||||
|
||||
impl EditorRuntimeActor {
|
||||
/// 连接广播 channel(Phase C:事件 stream delta)
|
||||
pub fn set_block_delta_tx(&self, tx: tokio::sync::broadcast::Sender<Value>) {
|
||||
if let Ok(mut guard) = self.block_delta_tx.write() {
|
||||
*guard = Some(tx);
|
||||
}
|
||||
}
|
||||
|
||||
/// apply_command 完成后尝试推送 block.delta 到广播(Phase C)
|
||||
pub fn try_push_block_delta(&self, delta_json: &Value) {
|
||||
if let Ok(guard) = self.block_delta_tx.read() {
|
||||
if let Some(ref tx) = *guard {
|
||||
let _ = tx.send(delta_json.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 在 apply 后构建 BlockDelta(Phase B 用于推送编辑器)
|
||||
pub fn build_block_delta(
|
||||
&self,
|
||||
document_id: &str,
|
||||
command: &EditorCommand,
|
||||
) -> Result<BlockDelta, WebError> {
|
||||
let documents = self
|
||||
.documents
|
||||
.read()
|
||||
.map_err(|e| WebError::internal(format!("EditorRuntimeActor 锁失败:{e}")))?;
|
||||
let state = documents.get(document_id).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_editor_document_not_loaded", format!("文档 {document_id} 尚未加载"))
|
||||
})?;
|
||||
|
||||
let operations = match command {
|
||||
EditorCommand::ReplaceBlock(cmd) => {
|
||||
let block = state.document.blocks.iter().find(|b| b.block_id == cmd.block_id);
|
||||
vec![DeltaOperation::ReplaceBlock {
|
||||
block_id: cmd.block_id.clone(),
|
||||
text: block_text_from_block(block),
|
||||
block_type: block.map(|b| block_type_name(&b.block_type)),
|
||||
}]
|
||||
}
|
||||
EditorCommand::InsertBlockAfter(cmd) => {
|
||||
let new_block = state
|
||||
.document
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|b| b.block_id == cmd.block.block_id);
|
||||
vec![DeltaOperation::InsertBlockAfter {
|
||||
anchor_block_id: cmd.after_block_id.clone(),
|
||||
block_id: cmd.block.block_id.clone(),
|
||||
text: block_text_from_block(new_block),
|
||||
block_type: new_block.map(|b| block_type_name(&b.block_type)),
|
||||
}]
|
||||
}
|
||||
EditorCommand::DeleteBlock(cmd) => {
|
||||
vec![DeltaOperation::DeleteBlock {
|
||||
block_id: cmd.block_id.clone(),
|
||||
}]
|
||||
}
|
||||
EditorCommand::MoveBlock(cmd) => {
|
||||
let anchor = cmd.after_block_id.clone().unwrap_or_default();
|
||||
vec![DeltaOperation::MoveBlock {
|
||||
block_id: cmd.block_id.clone(),
|
||||
anchor_block_id: anchor,
|
||||
}]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
Ok(BlockDelta {
|
||||
document_id: document_id.to_string(),
|
||||
revision: state.revision,
|
||||
conflict_detection_key: state.conflict_detection_key.clone(),
|
||||
operations,
|
||||
})
|
||||
}
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
documents: Arc::new(RwLock::new(HashMap::new())),
|
||||
block_delta_tx: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取或初始化文档内存态。
|
||||
pub fn load_or_init(
|
||||
&self,
|
||||
document_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
page_aggregate: &Value,
|
||||
) -> Result<(), WebError> {
|
||||
let mut documents = self
|
||||
.documents
|
||||
.write()
|
||||
.map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?;
|
||||
|
||||
if documents.contains_key(document_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = page_aggregate
|
||||
.pointer("/body/content")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]));
|
||||
let revision = page_aggregate
|
||||
.pointer("/body/revision")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(1);
|
||||
let conflict_detection_key = page_aggregate
|
||||
.pointer("/body/conflictDetectionKey")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let document = editor_document_from_legacy_content(document_id, &content);
|
||||
|
||||
let state = EditorDocumentState {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: workspace_id.map(ToString::to_string),
|
||||
document,
|
||||
revision,
|
||||
conflict_detection_key,
|
||||
last_applied_at: Instant::now(),
|
||||
};
|
||||
|
||||
documents.insert(document_id.to_string(), state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在内存中应用 EditorCommand,产生 diff,返回 ApplyResult。
|
||||
pub fn apply_command(
|
||||
&self,
|
||||
document_id: &str,
|
||||
command: EditorCommand,
|
||||
command_name: &str,
|
||||
) -> Result<ApplyResult, WebError> {
|
||||
let mut documents = self
|
||||
.documents
|
||||
.write()
|
||||
.map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?;
|
||||
|
||||
let state = documents.get_mut(document_id).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_editor_document_not_loaded",
|
||||
format!("文档 {document_id} 尚未加载到 EditorRuntimeActor"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let changed_blocks = extract_changed_blocks(&state.document, &command);
|
||||
|
||||
apply_editor_command_to_document(&mut state.document, command.clone()).map_err(|error| {
|
||||
WebError::bad_request_code("mnote_editor_command_failed", format!("{error:?}"))
|
||||
})?;
|
||||
|
||||
state.revision += 1;
|
||||
state.conflict_detection_key = format!(
|
||||
"{}:{}:{}",
|
||||
document_id,
|
||||
state.revision,
|
||||
state.last_applied_at.elapsed().as_micros()
|
||||
);
|
||||
state.last_applied_at = Instant::now();
|
||||
|
||||
Ok(ApplyResult {
|
||||
ok: true,
|
||||
command: command_name.to_string(),
|
||||
document_id: Some(document_id.to_string()),
|
||||
workspace_id: state.workspace_id.clone(),
|
||||
new_revision: state.revision,
|
||||
conflict_detection_key: state.conflict_detection_key.clone(),
|
||||
changed_blocks,
|
||||
warnings: vec![],
|
||||
blocked: false,
|
||||
risk: "low".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从内存态生成 legacy content(用于构建 Convex save payload)。
|
||||
pub fn legacy_content_for_save(&self, document_id: &str) -> Result<Value, WebError> {
|
||||
let documents = self
|
||||
.documents
|
||||
.read()
|
||||
.map_err(|error| WebError::internal(format!("EditorRuntimeActor 锁失败:{error}")))?;
|
||||
let state = documents.get(document_id).ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_editor_document_not_loaded",
|
||||
format!("文档 {document_id} 尚未加载到 EditorRuntimeActor"),
|
||||
)
|
||||
})?;
|
||||
Ok(legacy_content_from_editor_document(&state.document))
|
||||
}
|
||||
|
||||
pub fn current_revision(&self, document_id: &str) -> Option<u64> {
|
||||
self.documents
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|documents| documents.get(document_id).map(|state| state.revision))
|
||||
}
|
||||
|
||||
pub fn current_conflict_detection_key(&self, document_id: &str) -> Option<String> {
|
||||
self.documents.read().ok().and_then(|documents| {
|
||||
documents
|
||||
.get(document_id)
|
||||
.map(|state| state.conflict_detection_key.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_loaded(&self, document_id: &str) -> bool {
|
||||
self.documents
|
||||
.read()
|
||||
.ok()
|
||||
.map(|documents| documents.contains_key(document_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_changed_blocks(
|
||||
document: &EditorBlockDocument,
|
||||
command: &EditorCommand,
|
||||
) -> Vec<ChangedBlock> {
|
||||
match command {
|
||||
EditorCommand::ReplaceBlock(command) => {
|
||||
let before = document
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|block| block.block_id == command.block_id)
|
||||
.and_then(block_text_summary);
|
||||
let after = block_text_from_content_nodes(&command.content_nodes);
|
||||
vec![ChangedBlock {
|
||||
block_id: command.block_id.clone(),
|
||||
op: "replace".to_string(),
|
||||
before,
|
||||
after,
|
||||
}]
|
||||
}
|
||||
EditorCommand::InsertBlockAfter(command) => {
|
||||
let after = block_text_from_content_nodes(&Some(command.block.content_nodes.clone()));
|
||||
vec![ChangedBlock {
|
||||
block_id: command.block.block_id.clone(),
|
||||
op: "insert_after".to_string(),
|
||||
before: None,
|
||||
after,
|
||||
}]
|
||||
}
|
||||
EditorCommand::DeleteBlock(command) => {
|
||||
let before = document
|
||||
.blocks
|
||||
.iter()
|
||||
.find(|block| block.block_id == command.block_id)
|
||||
.and_then(block_text_summary);
|
||||
vec![ChangedBlock {
|
||||
block_id: command.block_id.clone(),
|
||||
op: "delete".to_string(),
|
||||
before,
|
||||
after: None,
|
||||
}]
|
||||
}
|
||||
EditorCommand::MoveBlock(command) => {
|
||||
vec![ChangedBlock {
|
||||
block_id: command.block_id.clone(),
|
||||
op: "move".to_string(),
|
||||
before: None,
|
||||
after: None,
|
||||
}]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn block_text_summary(block: &EditorBlock) -> Option<String> {
|
||||
let text = block
|
||||
.content_nodes
|
||||
.iter()
|
||||
.filter_map(|node| match &node.payload {
|
||||
ContentNodePayload::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn block_text_from_content_nodes(nodes: &Option<Vec<ContentNode>>) -> Option<String> {
|
||||
nodes.as_ref().map(|nodes| {
|
||||
nodes
|
||||
.iter()
|
||||
.filter_map(|node| match &node.payload {
|
||||
ContentNodePayload::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 EditorBlock 提取纯文本
|
||||
fn block_text_from_block(block: Option<&EditorBlock>) -> String {
|
||||
block.map(block_text_summary).flatten().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// EditorBlockType → 字符串
|
||||
fn block_type_name(block_type: &EditorBlockType) -> String {
|
||||
match block_type {
|
||||
EditorBlockType::Paragraph => "paragraph".into(),
|
||||
EditorBlockType::Heading => "heading".into(),
|
||||
EditorBlockType::Todo => "todo".into(),
|
||||
EditorBlockType::BulletListItem => "bullet_list".into(),
|
||||
EditorBlockType::NumberedListItem => "ordered_list".into(),
|
||||
EditorBlockType::CodeBlock => "code".into(),
|
||||
EditorBlockType::Quote => "blockquote".into(),
|
||||
EditorBlockType::Divider => "divider".into(),
|
||||
EditorBlockType::Image => "image".into(),
|
||||
EditorBlockType::Table => "table".into(),
|
||||
EditorBlockType::Mindmap => "mindmap".into(),
|
||||
EditorBlockType::Toc => "table_of_contents".into(),
|
||||
EditorBlockType::PageReference => "page_reference".into(),
|
||||
EditorBlockType::BlockReference => "block_reference".into(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub async fn doc_fetch(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let document_id = input.effective_document_id().unwrap_or_default();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let scope = input
|
||||
.arg_string("scope")
|
||||
.unwrap_or_else(|| "full".into())
|
||||
.to_ascii_lowercase();
|
||||
let detail = input
|
||||
.arg_string("detail")
|
||||
.unwrap_or_else(|| "with_ids".into())
|
||||
.to_ascii_lowercase();
|
||||
let max_blocks = input
|
||||
.arg_value("maxBlocks")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(120)
|
||||
.clamp(1, 240) as usize;
|
||||
let mut blocks = block_projection_blocks(&aggregate);
|
||||
blocks = match scope.as_str() {
|
||||
"outline" => blocks
|
||||
.into_iter()
|
||||
.filter(|block| block.get("type").and_then(Value::as_str) == Some("heading"))
|
||||
.collect(),
|
||||
"block" => {
|
||||
let block_id = input.arg_string("blockId").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.doc.fetch scope=block 缺少 blockId",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
blocks
|
||||
.into_iter()
|
||||
.filter(|block| block_id_of(block).as_deref() == Some(block_id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
"keyword" => {
|
||||
let query = input.arg_string("query").ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.doc.fetch scope=keyword 缺少 query",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
filter_blocks_by_query(blocks, &query)
|
||||
}
|
||||
"selection" => {
|
||||
let selected_ids = selected_block_ids(input);
|
||||
if selected_ids.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.doc.fetch scope=selection 缺少 selectedBlockIds",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
blocks
|
||||
.into_iter()
|
||||
.filter(|block| {
|
||||
block_id_of(block)
|
||||
.map(|block_id| selected_ids.contains(&block_id))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => blocks,
|
||||
};
|
||||
let truncated = blocks.len() > max_blocks;
|
||||
blocks.truncate(max_blocks);
|
||||
let format = input
|
||||
.arg_string("format")
|
||||
.unwrap_or_else(|| "json".into())
|
||||
.to_ascii_lowercase();
|
||||
let include_ids = detail == "with_ids" || detail == "full";
|
||||
let content = blocks_to_content(&format, &blocks, include_ids, &document_id, &aggregate);
|
||||
let warnings = if truncated {
|
||||
json!([{
|
||||
"code": "mnote_doc_fetch_truncated",
|
||||
"message": "结果已按 maxBlocks 裁剪",
|
||||
"maxBlocks": max_blocks
|
||||
}])
|
||||
} else {
|
||||
json!([])
|
||||
};
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"format": format,
|
||||
"detail": detail,
|
||||
"scope": scope,
|
||||
"content": content,
|
||||
"blocks": blocks,
|
||||
"allowedTargetBlockIds": selected_block_ids(input),
|
||||
"truncated": truncated,
|
||||
"continuation": if truncated { json!({"maxBlocks": max_blocks}) } else { Value::Null },
|
||||
"warnings": warnings
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn doc_find(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let query = input.arg_string("query").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let match_kind = input
|
||||
.arg_string("match")
|
||||
.unwrap_or_else(|| "text".into())
|
||||
.to_ascii_lowercase();
|
||||
let limit = input
|
||||
.arg_value("limit")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(20)
|
||||
.clamp(1, 50) as usize;
|
||||
let mut matches = Vec::new();
|
||||
for block in block_projection_blocks(&aggregate) {
|
||||
let matched = match match_kind.as_str() {
|
||||
"type" => block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.eq_ignore_ascii_case(&query))
|
||||
.unwrap_or(false),
|
||||
"block_id" | "blockid" => block_id_of(&block).as_deref() == Some(query.as_str()),
|
||||
_ => block
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(|text| text.contains(&query))
|
||||
.unwrap_or(false),
|
||||
};
|
||||
if matched {
|
||||
matches.push(json!({
|
||||
"blockId": block.get("blockId").cloned().unwrap_or(Value::Null),
|
||||
"type": block.get("type").cloned().unwrap_or(Value::Null),
|
||||
"text": block.get("text").cloned().unwrap_or(Value::Null),
|
||||
"path": block.get("path").cloned().unwrap_or(Value::Null),
|
||||
"revisionRef": block.get("revisionRef").cloned().unwrap_or(Value::Null),
|
||||
"score": 1.0
|
||||
}));
|
||||
if matches.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"documentId": input.effective_document_id(),
|
||||
"workspaceId": input.effective_workspace_id(),
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"matches": matches
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn plan_update(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_idempotency_required",
|
||||
"写入计划型 mnote Hermes tool 必须携带 idempotencyKey",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.dry_run != Some(true) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_dry_run_required",
|
||||
"mnote.doc.plan_update 第一阶段只允许 dryRun=true",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let command = input
|
||||
.arg_string("command")
|
||||
.unwrap_or_else(|| "block_replace".into())
|
||||
.to_ascii_lowercase();
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
let diff = match command.as_str() {
|
||||
"block_replace" => {
|
||||
let block_id = required_arg(input, context, "blockId")?;
|
||||
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
||||
vec![json!({
|
||||
"op": "replace",
|
||||
"targetBlockId": block_id,
|
||||
"before": block.get("text").cloned().unwrap_or(Value::Null),
|
||||
"after": input.arg_value("content").unwrap_or(Value::Null)
|
||||
})]
|
||||
}
|
||||
"block_insert_after" => {
|
||||
let anchor = input
|
||||
.arg_string("anchorBlockId")
|
||||
.or_else(|| input.arg_string("afterBlockId"))
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.doc.plan_update block_insert_after 缺少 anchorBlockId",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let block = find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?;
|
||||
vec![json!({
|
||||
"op": "insert_after",
|
||||
"anchorBlockId": anchor,
|
||||
"after": block.get("text").cloned().unwrap_or(Value::Null),
|
||||
"content": input.arg_value("content").unwrap_or(Value::Null)
|
||||
})]
|
||||
}
|
||||
"block_move_after" => {
|
||||
let block_id = required_arg(input, context, "blockId")?;
|
||||
let anchor = required_arg(input, context, "anchorBlockId")?;
|
||||
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
||||
let anchor_block =
|
||||
find_block(&blocks, &anchor).ok_or_else(|| block_not_found(context))?;
|
||||
let blocked = block_move_after_blocked(&block, &anchor_block, &block_id, &anchor);
|
||||
vec![json!({
|
||||
"op": "move_after",
|
||||
"blockId": block_id,
|
||||
"anchorBlockId": anchor,
|
||||
"supportedForWrite": !blocked,
|
||||
"blocked": blocked
|
||||
})]
|
||||
}
|
||||
"block_delete" => {
|
||||
let block_id = required_arg(input, context, "blockId")?;
|
||||
let block = find_block(&blocks, &block_id).ok_or_else(|| block_not_found(context))?;
|
||||
let blocked = block
|
||||
.get("children")
|
||||
.and_then(Value::as_array)
|
||||
.map(|children| !children.is_empty())
|
||||
.unwrap_or(false);
|
||||
vec![json!({
|
||||
"op": "delete",
|
||||
"blockId": block_id,
|
||||
"before": block.get("text").cloned().unwrap_or(Value::Null),
|
||||
"supportedForWrite": !blocked,
|
||||
"blocked": blocked
|
||||
})]
|
||||
}
|
||||
"str_replace" => vec![json!({
|
||||
"op": "str_replace",
|
||||
"query": input.arg_value("query").unwrap_or(Value::Null),
|
||||
"replacement": input.arg_value("content").unwrap_or(Value::Null)
|
||||
})],
|
||||
other => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("mnote.doc.plan_update 不支持 command={other}"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
};
|
||||
let plan_blocked = matches!(command.as_str(), "block_move_after" | "block_delete")
|
||||
&& diff
|
||||
.first()
|
||||
.and_then(|item| item.get("blocked"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"dryRun": true,
|
||||
"planId": format!("plan_{}", context.trace.request_id),
|
||||
"documentId": input.effective_document_id(),
|
||||
"workspaceId": input.effective_workspace_id(),
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"command": command,
|
||||
"diff": diff,
|
||||
"warnings": if plan_blocked {
|
||||
json!([{
|
||||
"code": "block_move_after_blocked",
|
||||
"message": "第一阶段仅开放同父级普通叶子块移动,且不能移动到自身之后"
|
||||
}])
|
||||
} else {
|
||||
json!([])
|
||||
},
|
||||
"risk": if command == "block_move_after" { "medium" } else { "low" },
|
||||
"blocked": plan_blocked
|
||||
}))
|
||||
}
|
||||
|
||||
fn block_move_after_blocked(
|
||||
block: &Value,
|
||||
anchor: &Value,
|
||||
block_id: &str,
|
||||
anchor_id: &str,
|
||||
) -> bool {
|
||||
let same_parent = block.get("parentBlockId") == anchor.get("parentBlockId");
|
||||
let leaf = block
|
||||
.get("children")
|
||||
.and_then(Value::as_array)
|
||||
.map(|children| children.is_empty())
|
||||
.unwrap_or(true);
|
||||
let movable_type = block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(|block_type| matches!(block_type, "paragraph" | "heading" | "todo" | "task"))
|
||||
.unwrap_or(false);
|
||||
let editable = block
|
||||
.get("editable")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
!same_parent || !leaf || !movable_type || !editable || block_id == anchor_id
|
||||
}
|
||||
|
||||
pub(crate) async fn aggregate_value(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id = input.effective_document_id().ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "页面工具缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
state,
|
||||
context,
|
||||
&document_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
|
||||
aggregate
|
||||
.pointer("/body/blockDocument/blocks")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn block_id_of(block: &Value) -> Option<String> {
|
||||
block
|
||||
.get("blockId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn find_block(blocks: &[Value], block_id: &str) -> Option<Value> {
|
||||
blocks
|
||||
.iter()
|
||||
.find(|block| block_id_of(block).as_deref() == Some(block_id))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn required_arg(
|
||||
input: &ToolCallInput,
|
||||
context: &RequestContext,
|
||||
key: &'static str,
|
||||
) -> Result<String, WebError> {
|
||||
input.arg_string(key).ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", format!("工具调用缺少 {key}"))
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn block_not_found(context: &RequestContext) -> WebError {
|
||||
WebError::bad_request_code("mnote_block_not_found", "块不存在").with_context(context)
|
||||
}
|
||||
|
||||
fn filter_blocks_by_query(blocks: Vec<Value>, query: &str) -> Vec<Value> {
|
||||
blocks
|
||||
.into_iter()
|
||||
.filter(|block| {
|
||||
block
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(|text| text.contains(query))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selected_block_ids(input: &ToolCallInput) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut ids = Vec::new();
|
||||
for key in ["selectedBlockIds", "allowedTargetBlockIds"] {
|
||||
if let Some(Value::Array(values)) = input.arg_value(key) {
|
||||
for value in values {
|
||||
if let Some(id) = value.as_str().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
if seen.insert(id.to_string()) {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in ["selectedBlockId", "blockId"] {
|
||||
if let Some(id) = input.arg_string(key) {
|
||||
if seen.insert(id.clone()) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn blocks_to_content(
|
||||
format: &str,
|
||||
blocks: &[Value],
|
||||
include_ids: bool,
|
||||
document_id: &str,
|
||||
aggregate: &Value,
|
||||
) -> String {
|
||||
match format {
|
||||
"page_xml" | "xml" => blocks_to_page_xml(blocks, document_id, aggregate),
|
||||
"text" | "plain" => blocks_to_text(blocks, include_ids),
|
||||
"markdown" | "md" => blocks_to_markdown(blocks, include_ids),
|
||||
_ => blocks_to_markdown(blocks, include_ids),
|
||||
}
|
||||
}
|
||||
|
||||
fn blocks_to_text(blocks: &[Value], include_ids: bool) -> String {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|block| {
|
||||
let text = block_text(block);
|
||||
if include_ids {
|
||||
format!("[{}] {text}", block_id_of(block).unwrap_or_default())
|
||||
} else {
|
||||
text
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn blocks_to_markdown(blocks: &[Value], include_ids: bool) -> String {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|block| {
|
||||
let text = block_text(block);
|
||||
let prefix = match block.get("type").and_then(Value::as_str) {
|
||||
Some("heading") => "## ",
|
||||
Some("todo") | Some("task") => "- [ ] ",
|
||||
_ => "",
|
||||
};
|
||||
if include_ids {
|
||||
format!(
|
||||
"{prefix}{text} <!-- block:{} -->",
|
||||
block_id_of(block).unwrap_or_default()
|
||||
)
|
||||
} else {
|
||||
format!("{prefix}{text}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn blocks_to_page_xml(blocks: &[Value], document_id: &str, aggregate: &Value) -> String {
|
||||
let revision = aggregate
|
||||
.pointer("/body/revision")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
aggregate
|
||||
.pointer("/body/revision")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
});
|
||||
let mut output = format!(
|
||||
"<page id=\"{}\" revision=\"{}\">",
|
||||
escape_xml(document_id),
|
||||
escape_xml(&revision)
|
||||
);
|
||||
for block in blocks {
|
||||
let block_id = block_id_of(block).unwrap_or_default();
|
||||
let block_type = block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("paragraph");
|
||||
let revision_ref = block
|
||||
.get("revisionRef")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
output.push_str(&format!(
|
||||
"\n <block id=\"{}\" type=\"{}\" revisionRef=\"{}\"",
|
||||
escape_xml(&block_id),
|
||||
escape_xml(block_type),
|
||||
escape_xml(revision_ref)
|
||||
));
|
||||
if let Some(level) = block
|
||||
.pointer("/attrs/level")
|
||||
.or_else(|| block.pointer("/props/level"))
|
||||
{
|
||||
if let Some(level) = level.as_u64() {
|
||||
output.push_str(&format!(" level=\"{}\"", level));
|
||||
}
|
||||
}
|
||||
output.push_str(&format!(">{}</block>", escape_xml(&block_text(block))));
|
||||
}
|
||||
output.push_str("\n</page>");
|
||||
output
|
||||
}
|
||||
|
||||
fn block_text(block: &Value) -> String {
|
||||
block
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn escape_xml(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -13,6 +13,15 @@ pub fn manifest() -> Value {
|
||||
"writeOwner": "rust-runtime-kernel"
|
||||
},
|
||||
"tools": [
|
||||
doc_fetch_tool(),
|
||||
doc_find_tool(),
|
||||
block_fetch_tool(),
|
||||
doc_plan_update_tool(),
|
||||
block_replace_tool(),
|
||||
block_insert_after_tool(),
|
||||
block_delete_tool(),
|
||||
block_move_after_tool(),
|
||||
doc_apply_block_ops_tool(),
|
||||
page_get_tool(),
|
||||
page_save_tool(),
|
||||
available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]),
|
||||
@@ -23,6 +32,306 @@ pub fn manifest() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn base_identity_properties() -> Value {
|
||||
json!({
|
||||
"workspaceId": { "type": "string" },
|
||||
"documentId": { "type": "string" },
|
||||
"sessionId": { "type": "string" },
|
||||
"runId": { "type": "string" },
|
||||
"toolCallId": { "type": "string" },
|
||||
"traceId": { "type": "string" },
|
||||
"actorId": { "type": "string" },
|
||||
"actorType": { "type": "string" }
|
||||
})
|
||||
}
|
||||
|
||||
fn doc_fetch_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert(
|
||||
"scope".into(),
|
||||
json!({ "type": "string", "default": "full" }),
|
||||
);
|
||||
map.insert(
|
||||
"detail".into(),
|
||||
json!({ "type": "string", "default": "with_ids" }),
|
||||
);
|
||||
map.insert(
|
||||
"format".into(),
|
||||
json!({ "type": "string", "enum": ["json", "markdown", "text", "page_xml"], "default": "json" }),
|
||||
);
|
||||
map.insert("blockId".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"selectedBlockIds".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
map.insert(
|
||||
"allowedTargetBlockIds".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"maxBlocks".into(),
|
||||
json!({ "type": "integer", "default": 120 }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.doc.fetch",
|
||||
"description": "读取当前页面的 canonical block projection,支持 full/outline/block/keyword 范围",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn doc_find_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"match".into(),
|
||||
json!({ "type": "string", "default": "text" }),
|
||||
);
|
||||
map.insert("limit".into(), json!({ "type": "integer", "default": 20 }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.doc.find",
|
||||
"description": "在 Page Aggregate block projection 中按文本、类型或 blockId 查找块",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn block_fetch_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("blockId".into(), json!({ "type": "string" }));
|
||||
map.insert(
|
||||
"includeChildren".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"contextBefore".into(),
|
||||
json!({ "type": "integer", "default": 1 }),
|
||||
);
|
||||
map.insert(
|
||||
"contextAfter".into(),
|
||||
json!({ "type": "integer", "default": 1 }),
|
||||
);
|
||||
map.insert(
|
||||
"format".into(),
|
||||
json!({ "type": "string", "enum": ["json", "markdown", "text", "page_xml"], "default": "json" }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.block.fetch",
|
||||
"description": "读取单个块及同父级上下文",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["block.read", "page.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "blockId"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn doc_plan_update_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.doc.plan_update",
|
||||
"生成页面块更新 dry-run 计划,不直接写入",
|
||||
["page.write", "block.write"],
|
||||
json!({
|
||||
"command": { "type": "string" },
|
||||
"blockId": { "type": "string" },
|
||||
"anchorBlockId": { "type": "string" },
|
||||
"content": { "type": ["string", "object", "array"] },
|
||||
"query": { "type": "string" },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" }
|
||||
}),
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
fn block_replace_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.block.replace",
|
||||
"替换指定块内容;真实写入走 Rust page.body.save 链路",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"blockId": { "type": "string" },
|
||||
"content": { "type": ["string", "object", "array"] },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" }
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
"content",
|
||||
"revision",
|
||||
"conflictDetectionKey",
|
||||
"blockRevisionRef",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn block_insert_after_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.block.insert_after",
|
||||
"在指定块后插入新块;真实写入走 Rust page.body.save 链路",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"anchorBlockId": { "type": "string" },
|
||||
"content": { "type": ["string", "object", "array"] },
|
||||
"block": { "type": "object" },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"anchorRevisionRef": { "type": "string" }
|
||||
}),
|
||||
[
|
||||
"anchorBlockId",
|
||||
"content",
|
||||
"revision",
|
||||
"conflictDetectionKey",
|
||||
"anchorRevisionRef",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn block_move_after_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.block.move_after",
|
||||
"受限同父级普通叶子块移动;真实写入走 Rust page.body.save 链路",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"blockId": { "type": "string" },
|
||||
"anchorBlockId": { "type": "string" },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" },
|
||||
"anchorRevisionRef": { "type": "string" }
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
"anchorBlockId",
|
||||
"revision",
|
||||
"conflictDetectionKey",
|
||||
"blockRevisionRef",
|
||||
"anchorRevisionRef",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn block_delete_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.block.delete",
|
||||
"删除指定无子块普通块;真实写入走 Rust page.body.save 链路",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"blockId": { "type": "string" },
|
||||
"revision": { "type": ["number", "string"] },
|
||||
"conflictDetectionKey": { "type": "string" },
|
||||
"blockRevisionRef": { "type": "string" }
|
||||
}),
|
||||
[
|
||||
"blockId",
|
||||
"revision",
|
||||
"conflictDetectionKey",
|
||||
"blockRevisionRef",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn doc_apply_block_ops_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.doc.apply_block_ops",
|
||||
"一次性应用多个块级操作;Rust 侧统一读取最新 projection、生成 canonical content 并一次保存,适合页面 AI 小段落增删改移动快路径",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": { "type": "string" },
|
||||
"blockId": { "type": "string" },
|
||||
"anchorBlockId": { "type": "string" },
|
||||
"matchText": { "type": "string" },
|
||||
"anchorText": { "type": "string" },
|
||||
"afterText": { "type": "string" },
|
||||
"allowedTargetBlockIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"content": { "type": ["string", "object", "array"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
["operations"],
|
||||
)
|
||||
}
|
||||
|
||||
fn write_tool(
|
||||
name: &str,
|
||||
description: &str,
|
||||
scope: impl IntoIterator<Item = &'static str>,
|
||||
extra_properties: Value,
|
||||
extra_required: impl IntoIterator<Item = &'static str>,
|
||||
) -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("dryRun".into(), json!({ "type": "boolean" }));
|
||||
map.insert("idempotencyKey".into(), json!({ "type": "string" }));
|
||||
if let Value::Object(extra) = extra_properties {
|
||||
for (key, value) in extra {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut required = vec![
|
||||
"workspaceId",
|
||||
"documentId",
|
||||
"sessionId",
|
||||
"runId",
|
||||
"toolCallId",
|
||||
"traceId",
|
||||
"actorId",
|
||||
"dryRun",
|
||||
"idempotencyKey",
|
||||
];
|
||||
required.extend(extra_required);
|
||||
json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": scope.into_iter().collect::<Vec<_>>(),
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(false, false, false, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": required,
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn page_get_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.page.get",
|
||||
@@ -31,7 +340,7 @@ fn page_get_tool() -> Value {
|
||||
"capabilityScope": ["page.read"],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId"],
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId"],
|
||||
"properties": {
|
||||
"workspaceId": { "type": "string" },
|
||||
"documentId": { "type": "string" },
|
||||
@@ -39,6 +348,8 @@ fn page_get_tool() -> Value {
|
||||
"runId": { "type": "string" },
|
||||
"toolCallId": { "type": "string" },
|
||||
"traceId": { "type": "string" },
|
||||
"actorId": { "type": "string" },
|
||||
"actorType": { "type": "string" },
|
||||
"includeBody": { "type": "boolean", "default": true },
|
||||
"includeOptions": { "type": "boolean", "default": true },
|
||||
"includeBlocks": { "type": "boolean", "default": true }
|
||||
@@ -54,9 +365,10 @@ fn page_save_tool() -> Value {
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.write"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(false, true, false, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "content", "dryRun", "idempotencyKey"],
|
||||
"required": ["workspaceId", "documentId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "content", "dryRun", "idempotencyKey"],
|
||||
"properties": {
|
||||
"workspaceId": { "type": "string" },
|
||||
"documentId": { "type": "string" },
|
||||
@@ -64,6 +376,8 @@ fn page_save_tool() -> Value {
|
||||
"runId": { "type": "string" },
|
||||
"toolCallId": { "type": "string" },
|
||||
"traceId": { "type": "string" },
|
||||
"actorId": { "type": "string" },
|
||||
"actorType": { "type": "string" },
|
||||
"content": {
|
||||
"description": "要写入的正文块数组、{blocks:[...]}、TipTap content 数组或纯文本",
|
||||
"type": ["array", "object", "string"]
|
||||
@@ -90,6 +404,25 @@ fn available_tool(
|
||||
"description": description,
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": scope.into_iter().collect::<Vec<_>>(),
|
||||
"status": "available"
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(false, false, false, false)
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_annotations(
|
||||
readonly: bool,
|
||||
destructive: bool,
|
||||
idempotent: bool,
|
||||
requires_approval: bool,
|
||||
) -> Value {
|
||||
json!({
|
||||
"readonly": readonly,
|
||||
"destructive": destructive,
|
||||
"idempotent": idempotent,
|
||||
"requiresApproval": requires_approval,
|
||||
"approvalMode": if requires_approval { "review" } else { "yolo" },
|
||||
"runtimeOwner": "mnote-web",
|
||||
"writeOwner": "rust-runtime-kernel",
|
||||
"selectionEffect": if readonly { "preserve" } else { "may_change" }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod artifact;
|
||||
pub mod block;
|
||||
pub mod doc;
|
||||
pub mod manifest;
|
||||
pub mod page;
|
||||
|
||||
@@ -12,6 +14,7 @@ pub struct ToolCallInput {
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub run_id: Option<String>,
|
||||
pub tool_call_id: Option<String>,
|
||||
|
||||
@@ -43,7 +43,11 @@ pub async fn page_get(
|
||||
.or_else(|| aggregate_value.pointer("/layout/page_options"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let blocks = summarize_blocks(&content);
|
||||
let blocks = aggregate_value
|
||||
.pointer("/body/blockDocument/blocks")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| summarize_blocks(&content));
|
||||
let body_summary = blocks
|
||||
.iter()
|
||||
.filter_map(|block| block.get("text").and_then(Value::as_str))
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#![recursion_limit = "1024"]
|
||||
|
||||
pub mod app;
|
||||
pub mod context;
|
||||
pub mod editor_actor;
|
||||
pub mod error;
|
||||
pub mod hermes_tools;
|
||||
pub mod local_folder_watcher_registry;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![recursion_limit = "512"]
|
||||
|
||||
use mnote_web::{build_app, AppConfig, AppState};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::page_aggregate::{
|
||||
PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
|
||||
PagePermissions, PageStats, PageTree,
|
||||
};
|
||||
use bridge_runtime::project_legacy_content_to_block_document;
|
||||
use serde_json::{to_value, Value};
|
||||
|
||||
/// PageAggregate 构建器。
|
||||
@@ -284,6 +285,13 @@ impl PageAggregateBuilder {
|
||||
|
||||
/// 消费 builder 并产出 `PageAggregate`。
|
||||
pub fn build(self) -> PageAggregate {
|
||||
let block_document = project_legacy_content_to_block_document(
|
||||
&self.document_id,
|
||||
&self.content,
|
||||
&self.revision,
|
||||
)
|
||||
.ok();
|
||||
let block_projection_version = if block_document.is_some() { 1 } else { 0 };
|
||||
let page_options = PageOptions {
|
||||
wide_layout: self.wide_layout,
|
||||
small_text: self.small_text,
|
||||
@@ -336,6 +344,9 @@ impl PageAggregateBuilder {
|
||||
content: self.content,
|
||||
revision: self.revision,
|
||||
conflict_detection_key: self.conflict_detection_key,
|
||||
block_document: block_document.unwrap_or(Value::Null),
|
||||
block_projection_version,
|
||||
projection_source: "builder.content".into(),
|
||||
},
|
||||
tree: PageTree {
|
||||
page_subtree: self.page_subtree,
|
||||
|
||||
@@ -166,6 +166,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -72,6 +72,7 @@ mod tests {
|
||||
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,
|
||||
@@ -110,6 +111,7 @@ mod tests {
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
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,
|
||||
@@ -165,6 +167,7 @@ mod tests {
|
||||
legacy_next_base_url: Some("http://127.0.0.1:9".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
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,
|
||||
@@ -232,6 +235,7 @@ mod tests {
|
||||
legacy_next_base_url: Some(format!("http://{}", addr)),
|
||||
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,
|
||||
|
||||
@@ -947,6 +947,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -1778,6 +1778,7 @@ mod tests {
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
|
||||
@@ -1413,6 +1413,7 @@ mod tests {
|
||||
legacy_next_base_url: Some(legacy_next_base_url),
|
||||
enable_legacy_next_compat,
|
||||
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,
|
||||
|
||||
@@ -165,6 +165,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::manifest;
|
||||
use crate::transport::convex::execute_convex_query_by_name;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
@@ -80,7 +81,11 @@ pub async fn list_sessions(
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
let profile = query
|
||||
.get("profile")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("default");
|
||||
let Some(upstream) = configured_upstream_for_profile(profile) else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
let mut path = "/api/hermes/sessions".to_string();
|
||||
@@ -93,7 +98,15 @@ pub async fn list_sessions(
|
||||
path.push('?');
|
||||
path.push_str(¶ms);
|
||||
}
|
||||
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
|
||||
proxy_json(
|
||||
&context,
|
||||
reqwest::Method::GET,
|
||||
&upstream,
|
||||
&path,
|
||||
None,
|
||||
Some(profile),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
@@ -157,7 +170,7 @@ pub async fn gateway_health(
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(active_profile_name)
|
||||
.unwrap_or_else(|| "default".into());
|
||||
let upstream = configured_upstream();
|
||||
let upstream = configured_upstream_for_profile(&profile);
|
||||
let profile_status = profile_gateway_status(&profile);
|
||||
let mut suggestions = profile_status
|
||||
.get("suggestions")
|
||||
@@ -178,7 +191,8 @@ pub async fn gateway_health(
|
||||
"status": if upstream.is_some() { "checking" } else { "unconfigured" }
|
||||
});
|
||||
if let Some(upstream_url) = gateway["upstream"].as_str().map(ToOwned::to_owned) {
|
||||
let probe = probe_gateway_health(&upstream_url).await;
|
||||
let probe =
|
||||
probe_gateway_health(&upstream_url, configured_api_key_for_profile(&profile)).await;
|
||||
gateway["ok"] = Value::Bool(probe.ok);
|
||||
gateway["status"] = Value::String(probe.status);
|
||||
gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null);
|
||||
@@ -387,6 +401,52 @@ pub async fn toggle_skill(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn toggle_tool(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let name = payload
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("hermes_client_bad_request", "缺少 tool name")
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
let enabled = payload
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled")
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
|
||||
let profile = payload
|
||||
.get("profile")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(fallback_profile.as_str());
|
||||
set_mnote_tool_enabled(profile, name, enabled).map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"hermes_client_tool_toggle_failed",
|
||||
format!("更新 mnote tool 设置失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({"ok": true})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -466,7 +526,7 @@ pub async fn create_run(
|
||||
let queued = enqueue_run(&context, ®istration, &payload)?;
|
||||
return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
|
||||
}
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
let Some(upstream) = configured_upstream_for_profile(®istration.profile) else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
let upstream_body = build_run_upstream_body(&context, payload)?;
|
||||
@@ -476,6 +536,7 @@ pub async fn create_run(
|
||||
&upstream,
|
||||
"/v1/runs",
|
||||
Some(upstream_body),
|
||||
Some(®istration.profile),
|
||||
)
|
||||
.await?;
|
||||
if let Some(runtime) = register_runtime_from_create_run_response(®istration, &result.2 .0) {
|
||||
@@ -511,7 +572,8 @@ pub async fn stream_events(
|
||||
Path(run_id): Path<String>,
|
||||
) -> Result<Response, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
|
||||
let Some(upstream) = configured_upstream_for_profile(&profile) else {
|
||||
return Err(hermes_unconfigured_error(&context));
|
||||
};
|
||||
let url = upstream_url(
|
||||
@@ -525,7 +587,7 @@ pub async fn stream_events(
|
||||
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(&context)
|
||||
})?
|
||||
.get(url);
|
||||
if let Some(api_key) = configured_api_key() {
|
||||
if let Some(api_key) = configured_api_key_for_profile(&profile) {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
let upstream_response = request.send().await.map_err(|error| {
|
||||
@@ -583,7 +645,8 @@ pub async fn abort_run(
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
|
||||
let Some(upstream) = configured_upstream_for_profile(&profile) else {
|
||||
return hermes_unconfigured(&context);
|
||||
};
|
||||
let queued_session_id = session_id_for_run(&run_id);
|
||||
@@ -594,6 +657,7 @@ pub async fn abort_run(
|
||||
&upstream,
|
||||
&format!("/v1/runs/{}/stop", url_escape(&run_id)),
|
||||
Some(payload),
|
||||
Some(&profile),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
@@ -629,70 +693,29 @@ pub async fn list_models(
|
||||
&upstream,
|
||||
"/v1/models",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_tools(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into());
|
||||
let profile = query
|
||||
.get("profile")
|
||||
.map(String::as_str)
|
||||
.unwrap_or(fallback_profile.as_str());
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
stamp_client_headers(),
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"traceId": context.trace.trace_id,
|
||||
"tools": [
|
||||
{
|
||||
"name": "mnote.page.get",
|
||||
"scope": "page.read",
|
||||
"kind": "read",
|
||||
"schemaVersion": "mnote.hermes_tool.v1",
|
||||
"status": "available",
|
||||
"description": "读取当前页面正文、标题、设置与结构快照"
|
||||
},
|
||||
{
|
||||
"name": "mnote.page.save",
|
||||
"scope": "page.write",
|
||||
"kind": "write",
|
||||
"schemaVersion": "mnote.hermes_tool.v1",
|
||||
"status": "available",
|
||||
"description": "保存当前页面正文"
|
||||
},
|
||||
{
|
||||
"name": "mnote.page.update_title",
|
||||
"scope": "page.write",
|
||||
"kind": "write",
|
||||
"schemaVersion": "mnote.hermes_tool.v1",
|
||||
"status": "available",
|
||||
"description": "更新当前页面标题"
|
||||
},
|
||||
{
|
||||
"name": "mnote.page.update_options",
|
||||
"scope": "page.write",
|
||||
"kind": "write",
|
||||
"schemaVersion": "mnote.hermes_tool.v1",
|
||||
"status": "available",
|
||||
"description": "更新当前页面设置"
|
||||
},
|
||||
{
|
||||
"name": "mnote.artifact.create_summary",
|
||||
"scope": "artifact.write",
|
||||
"kind": "write",
|
||||
"schemaVersion": "mnote.hermes_tool.v1",
|
||||
"status": "available",
|
||||
"description": "为当前页面创建或更新 AI Summary"
|
||||
},
|
||||
{
|
||||
"name": "mnote.artifact.create_ai_note",
|
||||
"scope": "artifact.write",
|
||||
"kind": "write",
|
||||
"schemaVersion": "mnote.hermes_tool.v1",
|
||||
"status": "available",
|
||||
"description": "基于当前页面创建新的 AI Note"
|
||||
}
|
||||
]
|
||||
"profile": profile,
|
||||
"tools": mnote_tools_payload(profile)
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -717,7 +740,7 @@ fn hermes_home() -> PathBuf {
|
||||
.unwrap_or_else(|| PathBuf::from(".hermes"))
|
||||
}
|
||||
|
||||
fn active_profile_name() -> Option<String> {
|
||||
pub(crate) fn active_profile_name() -> Option<String> {
|
||||
fs::read_to_string(hermes_home().join("active_profile"))
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
@@ -948,6 +971,105 @@ fn disabled_skills(profile: &str) -> Vec<String> {
|
||||
disabled
|
||||
}
|
||||
|
||||
fn yaml_disabled_list(content: &str, path: &[&str]) -> Vec<String> {
|
||||
let mut stack: Vec<(usize, String)> = Vec::new();
|
||||
let mut values = 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('#') {
|
||||
continue;
|
||||
}
|
||||
let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
|
||||
if let Some(value) = trimmed.strip_prefix("- ") {
|
||||
let keys = stack
|
||||
.iter()
|
||||
.map(|(_, key)| key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
if keys == path {
|
||||
let normalized = value.trim().trim_matches('"').trim_matches('\'');
|
||||
if !normalized.is_empty() {
|
||||
values.push(normalized.to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
stack.push((indent, key));
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
pub(crate) fn disabled_mnote_tools(profile: &str) -> Vec<String> {
|
||||
let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default();
|
||||
yaml_disabled_list(&content, &["mnote", "tools", "disabled"])
|
||||
}
|
||||
|
||||
pub(crate) fn is_mnote_tool_disabled(profile: &str, name: &str) -> bool {
|
||||
disabled_mnote_tools(profile)
|
||||
.iter()
|
||||
.any(|item| item == name)
|
||||
}
|
||||
|
||||
fn mnote_tools_payload(profile: &str) -> Vec<Value> {
|
||||
let disabled = disabled_mnote_tools(profile);
|
||||
manifest::manifest()
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|tool| mnote_tool_entry(tool, &disabled))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mnote_tool_entry(tool: &Value, disabled: &[String]) -> Option<Value> {
|
||||
let name = tool.get("name").and_then(Value::as_str)?.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let capability_scope = tool
|
||||
.get("capabilityScope")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let disabled = disabled.iter().any(|item| item == name);
|
||||
let status = if disabled {
|
||||
"disabled"
|
||||
} else {
|
||||
tool.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("available")
|
||||
};
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"description": tool.get("description").and_then(Value::as_str).unwrap_or_default(),
|
||||
"scope": capability_scope.first().cloned().unwrap_or_else(|| "mnote".into()),
|
||||
"capabilityScope": capability_scope,
|
||||
"kind": if capability_scope.iter().any(|scope| scope.ends_with(".write")) { "write" } else { "read" },
|
||||
"schemaVersion": tool.get("schemaVersion").and_then(Value::as_str).unwrap_or(manifest::TOOL_SCHEMA_VERSION),
|
||||
"status": status,
|
||||
"enabled": !disabled,
|
||||
"unavailableReason": if disabled { "当前 Hermes profile 已关闭该 mnote tool" } else { "" }
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_skill_description(markdown: &str) -> String {
|
||||
markdown
|
||||
.lines()
|
||||
@@ -1237,6 +1359,73 @@ fn set_skill_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Resul
|
||||
fs::write(path, output)
|
||||
}
|
||||
|
||||
fn set_mnote_tool_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> {
|
||||
let path = profile_config_path(profile);
|
||||
let mut disabled = disabled_mnote_tools(profile);
|
||||
if enabled {
|
||||
disabled.retain(|item| item != name);
|
||||
} else if !disabled.iter().any(|item| item == name) {
|
||||
disabled.push(name.to_string());
|
||||
}
|
||||
disabled.sort();
|
||||
let existing = fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut kept = Vec::new();
|
||||
let mut stack: Vec<(usize, String)> = Vec::new();
|
||||
let mut skipping_disabled_list = false;
|
||||
let mut disabled_indent = 0usize;
|
||||
for line in existing.lines() {
|
||||
let trimmed = line.trim();
|
||||
let indent = line.chars().take_while(|ch| ch.is_whitespace()).count();
|
||||
if skipping_disabled_list {
|
||||
if trimmed.starts_with("- ") && indent > disabled_indent {
|
||||
continue;
|
||||
}
|
||||
skipping_disabled_list = false;
|
||||
}
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
kept.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
if !trimmed.starts_with("- ") {
|
||||
while stack
|
||||
.last()
|
||||
.map(|(level, _)| *level >= indent)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
stack.pop();
|
||||
}
|
||||
if let Some((key, _value)) = trimmed.split_once(':') {
|
||||
let key = key.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
stack.push((indent, key));
|
||||
let keys = stack
|
||||
.iter()
|
||||
.map(|(_, key)| key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
if keys == ["mnote", "tools", "disabled"] {
|
||||
skipping_disabled_list = true;
|
||||
disabled_indent = indent;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
kept.push(line.to_string());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output = kept.join("\n");
|
||||
if !output.ends_with('\n') && !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str("mnote:\n tools:\n disabled:\n");
|
||||
for tool in disabled {
|
||||
output.push_str(" - ");
|
||||
output.push_str(&tool);
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(path, output)
|
||||
}
|
||||
|
||||
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||
let has_actor = context.auth.actor_id.trim() != "anonymous";
|
||||
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
||||
@@ -1264,6 +1453,57 @@ fn configured_upstream() -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn profile_config_value(profile: &str, path: &[&str]) -> Option<String> {
|
||||
let content = fs::read_to_string(profile_config_path(profile)).ok()?;
|
||||
yaml_path_value(&content, path)
|
||||
}
|
||||
|
||||
fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String {
|
||||
let normalized = profile
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_uppercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
format!("{prefix}_{normalized}_{suffix}")
|
||||
}
|
||||
|
||||
fn configured_upstream_for_profile(profile: &str) -> Option<String> {
|
||||
let profile = profile.trim();
|
||||
if !profile.is_empty() && profile != "default" {
|
||||
let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "UPSTREAM_URL");
|
||||
if let Some(value) = env_or_dotenv(&env_key) {
|
||||
return Some(value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
}
|
||||
let enabled = profile_config_value(profile, &["API_SERVER_ENABLED"])
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"true" | "1" | "yes"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let port = profile_config_value(profile, &["API_SERVER_PORT"]);
|
||||
if enabled {
|
||||
if let Some(port) = port
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let host = profile_config_value(profile, &["API_SERVER_HOST"])
|
||||
.unwrap_or_else(|| "127.0.0.1".into());
|
||||
return Some(format!("http://{}:{}", host.trim(), port));
|
||||
}
|
||||
}
|
||||
}
|
||||
configured_upstream()
|
||||
}
|
||||
|
||||
fn configured_api_key() -> Option<String> {
|
||||
[
|
||||
"MNOTE_WEB_HERMES_API_KEY",
|
||||
@@ -1277,6 +1517,23 @@ fn configured_api_key() -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn configured_api_key_for_profile(profile: &str) -> Option<String> {
|
||||
let profile = profile.trim();
|
||||
if !profile.is_empty() && profile != "default" {
|
||||
let env_key = profile_env_key("MNOTE_WEB_HERMES", profile, "API_KEY");
|
||||
if let Some(value) = env_or_dotenv(&env_key) {
|
||||
return Some(value);
|
||||
}
|
||||
if let Some(value) = profile_config_value(profile, &["API_SERVER_KEY"]) {
|
||||
let trimmed = value.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
configured_api_key()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GatewayProbe {
|
||||
ok: bool,
|
||||
@@ -1286,7 +1543,7 @@ struct GatewayProbe {
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
async fn probe_gateway_health(upstream: &str) -> GatewayProbe {
|
||||
async fn probe_gateway_health(upstream: &str, api_key: Option<String>) -> GatewayProbe {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
@@ -1307,7 +1564,7 @@ async fn probe_gateway_health(upstream: &str) -> GatewayProbe {
|
||||
continue;
|
||||
};
|
||||
let mut request = client.get(url);
|
||||
if let Some(api_key) = configured_api_key() {
|
||||
if let Some(api_key) = api_key.as_deref() {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
match request.send().await {
|
||||
@@ -1555,6 +1812,29 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let tool_guidance = concat!(
|
||||
"每次调用 mnote 工具都必须显式透传 workspaceId、documentId、actorId、sessionId、runId、traceId;",
|
||||
"actorId 必须使用本 instructions 中的 actorId,不允许省略或填 hermes/anonymous。",
|
||||
"凡用户要求新增、删除、修改、替换、移动正文段落或块,必须走块级链路。",
|
||||
"本 instructions 的 pageContext.aiContext 是本次 run 开始时冻结的 mnote.page_ai_context.v1,",
|
||||
"其中 contextBlocks/pageXml/pageText 可直接作为 tiptapRead 风格的已读上下文使用;",
|
||||
"如果 aiContext 已包含足够唯一的目标文本或 blockId,应直接调用 mnote_doc_apply_block_ops,",
|
||||
"不要为了同一上下文再先调用 mnote_doc_fetch。",
|
||||
"scope=selection 时只能修改 allowedTargetBlockIds 内的块;",
|
||||
"简单小段落编辑或同一页内多个普通叶子块操作,应优先用 mnote_doc_apply_block_ops 一次提交 operations;",
|
||||
"该快路径等价于 Tiptap tiptapEdit 风格的批量编辑工具,可用唯一 matchText/anchorText 或已知 blockId/anchorBlockId 定位,",
|
||||
"避免 fetch、plan、多个单步写入造成多轮模型往返。",
|
||||
"只有当文本不唯一、目标不明确、涉及复杂块/子块/表格/资源块,或 apply_block_ops 返回歧义/不支持时,",
|
||||
"再降级为 mnote_doc_fetch(scope=full 或 keyword, detail=with_ids) 定位块,",
|
||||
"mnote_block_fetch 读取目标块 revisionRef/context,",
|
||||
"mnote_doc_plan_update(dryRun=true) 生成并检查 diff,",
|
||||
"最后调用 mnote_block_replace、mnote_block_insert_after、mnote_block_delete 或 mnote_block_move_after。",
|
||||
"写入后再 mnote_doc_fetch 或 mnote_block_fetch 回读验证。",
|
||||
"mnote_page_get 只用于读取页面标题、页面设置或粗略摘要;",
|
||||
"mnote_page_save 只允许在用户明确要求整页覆盖/整页追加且块级工具无法表达时作为高风险兜底,",
|
||||
"不能作为正文块新增、删除、修改、移动的首选工具。",
|
||||
"不要依据非 aiContext 的页面摘要猜测正文内容或块 id。"
|
||||
);
|
||||
let instructions = json!({
|
||||
"role": "mnote_page_ai_context",
|
||||
"workspaceId": workspace_id,
|
||||
@@ -1563,8 +1843,26 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
|
||||
"actorId": payload.get("actorId").and_then(Value::as_str).unwrap_or(&context.auth.actor_id),
|
||||
"actorType": payload.get("actorType").and_then(Value::as_str).unwrap_or(&context.auth.actor_type),
|
||||
"sessionId": session_id,
|
||||
"runId": payload
|
||||
.get("runId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(&session_id),
|
||||
"traceId": trace_id,
|
||||
"toolGuidance": "调用 mnote_page_get / mnote_page_save / mnote.page.* 对应工具时,必须透传 workspaceId、documentId、actorId、sessionId 和 traceId;读取页面标题或页面设置时调用 mnote_page_get,并传 includeBody=false、includeOptions=true、includeBlocks=false;需要正文摘要时才传 includeBody=true。写入正文时如需追加使用 mnote_page_save mode=append,覆盖全文才使用 mode=replace。不要只依据 pageContext 猜测。",
|
||||
"toolGuidance": tool_guidance,
|
||||
"blockEditingToolOrder": [
|
||||
"mnote_doc_apply_block_ops for simple small paragraph edits or multiple leaf-block operations",
|
||||
"mnote_doc_fetch | mnote_doc_find only when target text is ambiguous or structure is complex",
|
||||
"mnote_doc_fetch",
|
||||
"mnote_block_fetch",
|
||||
"mnote_doc_plan_update(dryRun=true)",
|
||||
"mnote_block_replace | mnote_block_insert_after | mnote_block_delete | mnote_block_move_after",
|
||||
"mnote_doc_fetch | mnote_block_fetch"
|
||||
],
|
||||
"pageSavePolicy": {
|
||||
"mnote_page_save": "fallback_only_for_explicit_whole_page_write",
|
||||
"forBlockEditing": "forbidden_as_first_choice"
|
||||
},
|
||||
"selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null),
|
||||
"selectedText": payload.get("selectedText").cloned().unwrap_or(Value::Null),
|
||||
"pageContext": sanitize_run_page_context(page_context)
|
||||
@@ -1655,6 +1953,7 @@ fn sanitize_run_page_context(page_context: Value) -> Value {
|
||||
"evidence",
|
||||
"pageOptions",
|
||||
"contentAccess",
|
||||
"aiContext",
|
||||
] {
|
||||
if let Some(value) = source.get(key) {
|
||||
sanitized.insert(key.to_string(), value.clone());
|
||||
@@ -1665,7 +1964,7 @@ fn sanitize_run_page_context(page_context: Value) -> Value {
|
||||
sanitized
|
||||
.get("contentAccess")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::String("mnote.page.get".into())),
|
||||
.unwrap_or_else(|| Value::String("mnote.doc.fetch".into())),
|
||||
);
|
||||
Value::Object(sanitized)
|
||||
}
|
||||
@@ -1989,6 +2288,16 @@ fn session_id_for_run(run_id: &str) -> Option<String> {
|
||||
.map(|state| state.session_id.clone())
|
||||
}
|
||||
|
||||
fn profile_for_run(run_id: &str) -> Option<String> {
|
||||
let registry = HERMES_RUNTIME_REGISTRY
|
||||
.lock()
|
||||
.expect("hermes runtime registry");
|
||||
registry
|
||||
.values()
|
||||
.find(|state| state.run_id == run_id)
|
||||
.map(|state| state.profile.clone())
|
||||
}
|
||||
|
||||
fn sse_json_events(chunk: &str) -> Vec<Value> {
|
||||
chunk
|
||||
.split("\n\n")
|
||||
@@ -2013,6 +2322,7 @@ async fn proxy_json(
|
||||
upstream: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
profile: Option<&str>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let url = upstream_url(upstream, path)?;
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -2022,7 +2332,10 @@ async fn proxy_json(
|
||||
WebError::internal(format!("Hermes client 构造失败: {error}")).with_context(context)
|
||||
})?;
|
||||
let mut request = client.request(method, url);
|
||||
if let Some(api_key) = configured_api_key() {
|
||||
if let Some(api_key) = profile
|
||||
.and_then(configured_api_key_for_profile)
|
||||
.or_else(configured_api_key)
|
||||
{
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
if let Some(body) = body {
|
||||
@@ -2147,7 +2460,7 @@ async fn start_next_queued_run(context: RequestContext, session_id: String) {
|
||||
let Some(queued) = pop_next_queued_run(&session_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(upstream) = configured_upstream() else {
|
||||
let Some(upstream) = configured_upstream_for_profile(&queued.profile) else {
|
||||
warn!(session_id = %session_id, queue_id = %queued.queue_id, "Hermes queue 无 upstream,无法自动启动下一条 run");
|
||||
return;
|
||||
};
|
||||
@@ -2171,6 +2484,7 @@ async fn start_next_queued_run(context: RequestContext, session_id: String) {
|
||||
&upstream,
|
||||
"/v1/runs",
|
||||
Some(upstream_body),
|
||||
Some(&queued.profile),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2417,6 +2731,7 @@ mod tests {
|
||||
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,
|
||||
@@ -2439,6 +2754,7 @@ mod tests {
|
||||
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,
|
||||
@@ -3189,7 +3505,13 @@ mod tests {
|
||||
"documentBlocks": [{"type": "paragraph", "text": "不应进入 Hermes instructions"}],
|
||||
"subtree": {"children": [{"title": "不应进入 Hermes instructions"}]},
|
||||
"outline": [{"title": "不应进入 Hermes instructions"}],
|
||||
"contentAccess": "mnote.page.get"
|
||||
"contentAccess": "mnote.page.get",
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"contextBlocks": [{"blockId": "block_1", "text": "允许进入 Hermes instructions"}],
|
||||
"pageXml": "<page><block id=\"block_1\">允许进入 Hermes instructions</block></page>",
|
||||
"allowedTargetBlockIds": ["block_1"]
|
||||
}
|
||||
},
|
||||
"selectedBlockId": "block_1",
|
||||
"selectedText": "选中文本",
|
||||
@@ -3205,6 +3527,8 @@ mod tests {
|
||||
assert!(instructions.contains("\"title\":\"页面标题\""));
|
||||
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
|
||||
assert!(instructions.contains("\"contentAccess\":\"mnote.page.get\""));
|
||||
assert!(instructions.contains("\"schema\":\"mnote.page_ai_context.v1\""));
|
||||
assert!(instructions.contains("允许进入 Hermes instructions"));
|
||||
assert!(!instructions.contains("不应进入 Hermes instructions"));
|
||||
assert!(!instructions.contains("\"documentBlocks\""));
|
||||
assert!(!instructions.contains("\"subtree\""));
|
||||
@@ -3404,4 +3728,118 @@ mod tests {
|
||||
std::env::remove_var("MNOTE_WEB_HERMES_BIN");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_client_tools_uses_manifest_and_profile_disabled_state() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-web-hermes-tools-local-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("chemist");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
"mnote:\n tools:\n disabled:\n - mnote.block.replace\n",
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.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("tools json");
|
||||
let tools = payload["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
(
|
||||
tool["name"].as_str().unwrap_or_default().to_string(),
|
||||
tool.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
assert!(tools.contains_key("mnote.doc.fetch"));
|
||||
assert!(tools.contains_key("mnote.block.fetch"));
|
||||
assert!(tools.contains_key("mnote.block.replace"));
|
||||
assert!(tools.contains_key("mnote.block.insert_after"));
|
||||
assert!(tools.contains_key("mnote.block.delete"));
|
||||
assert!(tools.contains_key("mnote.block.move_after"));
|
||||
assert!(tools.contains_key("mnote.doc.apply_block_ops"));
|
||||
assert_eq!(tools["mnote.doc.fetch"]["enabled"], true);
|
||||
assert_eq!(tools["mnote.block.replace"]["enabled"], false);
|
||||
assert_eq!(tools["mnote.block.replace"]["status"], "disabled");
|
||||
assert_eq!(
|
||||
tools["mnote.block.replace"]["unavailableReason"],
|
||||
"当前 Hermes profile 已关闭该 mnote tool"
|
||||
);
|
||||
|
||||
let toggle_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/hermes/client/tools/toggle")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"profile": "chemist",
|
||||
"name": "mnote.block.replace",
|
||||
"enabled": true
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(toggle_response.status(), StatusCode::OK);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/hermes/client/tools?scope=mnote&profile=chemist")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.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("tools json");
|
||||
let tools = payload["tools"]
|
||||
.as_array()
|
||||
.expect("tools")
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
(
|
||||
tool["name"].as_str().unwrap_or_default().to_string(),
|
||||
tool.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
assert_eq!(tools["mnote.block.replace"]["enabled"], true);
|
||||
assert_eq!(tools["mnote.block.replace"]["status"], "available");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::hermes_client;
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{artifact, manifest, page, ToolCallInput};
|
||||
use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -75,7 +76,7 @@ pub async fn mnote_call(
|
||||
let dry_run = input.dry_run.unwrap_or(false);
|
||||
let effect = if dry_run {
|
||||
"dry_run"
|
||||
} else if input.tool_name == "mnote.page.get" {
|
||||
} else if is_read_tool(&input.tool_name) {
|
||||
"read"
|
||||
} else {
|
||||
"write"
|
||||
@@ -98,6 +99,15 @@ pub async fn mnote_call(
|
||||
dry_run,
|
||||
"mnote Hermes tool call started"
|
||||
);
|
||||
let profile = input
|
||||
.profile
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| input.arg_string("profile"))
|
||||
.or_else(hermes_client::active_profile_name)
|
||||
.unwrap_or_else(|| "default".into());
|
||||
audit_push(json!({
|
||||
"phase": "started",
|
||||
"traceId": trace_id,
|
||||
@@ -107,9 +117,34 @@ pub async fn mnote_call(
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"dryRun": dry_run
|
||||
"actorId": input.actor_id,
|
||||
"dryRun": dry_run
|
||||
}));
|
||||
if hermes_client::is_mnote_tool_disabled(&profile, &input.tool_name) {
|
||||
let error = WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_disabled",
|
||||
"当前 Hermes profile 已关闭该 mnote tool",
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"profile": profile,
|
||||
"status": error.status().as_u16(),
|
||||
"message": error.message()
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = ensure_workspace_context(&context, workspace_id.as_deref()) {
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
@@ -150,6 +185,15 @@ pub async fn mnote_call(
|
||||
return Ok((StatusCode::OK, stamp_tool_headers(), Json(cached)));
|
||||
}
|
||||
let result = match input.tool_name.as_str() {
|
||||
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
|
||||
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
|
||||
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
|
||||
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
|
||||
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
|
||||
"mnote.block.insert_after" => block::block_insert_after(&state, &context, &input).await,
|
||||
"mnote.block.delete" => block::block_delete(&state, &context, &input).await,
|
||||
"mnote.block.move_after" => block::block_move_after(&state, &context, &input).await,
|
||||
"mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await,
|
||||
"mnote.page.get" => page::page_get(&state, &context, &input).await,
|
||||
"mnote.page.save" => page::page_save(&state, &context, &input).await,
|
||||
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
|
||||
@@ -240,6 +284,13 @@ pub async fn mnote_call(
|
||||
Ok((StatusCode::OK, stamp_tool_headers(), Json(response_body)))
|
||||
}
|
||||
|
||||
fn is_read_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch"
|
||||
)
|
||||
}
|
||||
|
||||
fn audit_log() -> &'static Mutex<Vec<Value>> {
|
||||
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
|
||||
LOG.get_or_init(|| Mutex::new(Vec::new()))
|
||||
@@ -332,7 +383,7 @@ fn idempotency_cache_key(
|
||||
document_id: Option<&str>,
|
||||
dry_run: bool,
|
||||
) -> Option<String> {
|
||||
if dry_run || input.tool_name == "mnote.page.get" {
|
||||
if dry_run || is_read_tool(&input.tool_name) {
|
||||
return None;
|
||||
}
|
||||
let idempotency_key = input.idempotency_key.as_deref()?.trim();
|
||||
@@ -359,7 +410,8 @@ fn idempotency_cache_put(key: String, response: Value) {
|
||||
}
|
||||
|
||||
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
|
||||
let has_actor = context.auth.actor_id.trim() != "anonymous";
|
||||
let actor = context.auth.actor_id.trim();
|
||||
let has_actor = actor != "anonymous" && actor != "hermes" && !actor.is_empty();
|
||||
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -377,24 +429,39 @@ fn authenticated_tool_context(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<RequestContext, WebError> {
|
||||
if ensure_authenticated(context).is_ok() {
|
||||
let context_actor = context.auth.actor_id.trim();
|
||||
if !context_actor.is_empty() && context_actor != "anonymous" && context_actor != "hermes" {
|
||||
return Ok(context.clone());
|
||||
}
|
||||
let has_cookie_or_auth =
|
||||
context.auth.authorization.is_some() || context.auth.cookie_header.is_some();
|
||||
let actor_id = input
|
||||
.actor_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "anonymous")
|
||||
.filter(|value| !value.is_empty() && *value != "anonymous" && *value != "hermes")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"mnote_tool_unauthorized",
|
||||
"mnote Hermes tool 需要登录后访问",
|
||||
"mnote Hermes tool 需要有效 actorId,不能使用 hermes/anonymous",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools")
|
||||
})?;
|
||||
if has_cookie_or_auth {
|
||||
let mut next = context.clone();
|
||||
next.auth.actor_id = actor_id.to_string();
|
||||
next.auth.actor_type = input
|
||||
.arg_string("actorType")
|
||||
.or_else(|| input.arg_string("actor_type"))
|
||||
.unwrap_or_else(|| "user".into());
|
||||
if next.auth.session_id.is_none() {
|
||||
next.auth.session_id = input.session_id.clone();
|
||||
}
|
||||
return Ok(next);
|
||||
}
|
||||
let has_run_identity = input
|
||||
.session_id
|
||||
.as_deref()
|
||||
@@ -487,8 +554,15 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -498,6 +572,7 @@ mod tests {
|
||||
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,
|
||||
@@ -523,6 +598,16 @@ mod tests {
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "章节一" }]
|
||||
},
|
||||
{
|
||||
"id": "p_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第一段" }]
|
||||
},
|
||||
{
|
||||
"id": "p_2",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第二段" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
@@ -531,13 +616,63 @@ mod tests {
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: None,
|
||||
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 call_tool_ok(payload: Value) -> Value {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(payload.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");
|
||||
serde_json::from_slice(&body).expect("json")
|
||||
}
|
||||
|
||||
async fn block_revision_ref(block_id: &str) -> String {
|
||||
let payload = call_tool_ok(json!({
|
||||
"toolName": "mnote.doc.fetch",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_ref",
|
||||
"runId": "run_ref",
|
||||
"toolCallId": format!("call_ref_{block_id}"),
|
||||
"traceId": format!("trace_ref_{block_id}"),
|
||||
"capabilityScope": ["page.read"],
|
||||
"args": {"scope": "full", "detail": "with_ids"}
|
||||
}))
|
||||
.await;
|
||||
payload["result"]["blocks"]
|
||||
.as_array()
|
||||
.expect("blocks")
|
||||
.iter()
|
||||
.find(|block| block["blockId"] == json!(block_id))
|
||||
.and_then(|block| block["revisionRef"].as_str())
|
||||
.expect("revisionRef")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_returns_first_batch_tools() {
|
||||
let response = app()
|
||||
@@ -558,6 +693,27 @@ mod tests {
|
||||
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.get"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.page.save"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.replace"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.insert_after"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.delete"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.move_after"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
@@ -622,6 +778,59 @@ mod tests {
|
||||
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_call_rejects_profile_disabled_tool() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-web-hermes-tool-disabled-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("blocked");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
"mnote:\n tools:\n disabled:\n - mnote.page.get\n",
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.get",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_disabled",
|
||||
"runId": "run_disabled",
|
||||
"toolCallId": "call_disabled",
|
||||
"traceId": "trace_disabled",
|
||||
"profile": "blocked",
|
||||
"capabilityScope": ["page.read"]
|
||||
})
|
||||
.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 hermes_tools_write_tools_require_auth() {
|
||||
let response = app()
|
||||
@@ -693,6 +902,540 @@ mod tests {
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_doc_fetch_returns_block_projection() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.fetch",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_doc_fetch_1",
|
||||
"traceId": "trace_doc_fetch_1",
|
||||
"capabilityScope": ["page.read"],
|
||||
"args": {"scope": "full", "detail": "with_ids"}
|
||||
})
|
||||
.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["toolName"], "mnote.doc.fetch");
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
assert_eq!(payload["result"]["revision"], json!(7));
|
||||
assert_eq!(
|
||||
payload["result"]["blocks"][0]["blockId"],
|
||||
json!("heading_1")
|
||||
);
|
||||
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
|
||||
assert!(payload["result"]["blocks"][0]["revisionRef"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("pageRev:7:block:heading_1:hash:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.fetch",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_doc_fetch_selection_1",
|
||||
"traceId": "trace_doc_fetch_selection_1",
|
||||
"capabilityScope": ["page.read"],
|
||||
"args": {
|
||||
"scope": "selection",
|
||||
"selectedBlockIds": ["heading_1"],
|
||||
"format": "page_xml"
|
||||
}
|
||||
})
|
||||
.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["result"]["schema"], "mnote.page_ai_context.v1");
|
||||
assert_eq!(payload["result"]["scope"], "selection");
|
||||
assert_eq!(payload["result"]["format"], "page_xml");
|
||||
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
|
||||
assert!(payload["result"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("<block id=\"heading_1\""));
|
||||
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_doc_find_and_block_fetch_use_block_projection() {
|
||||
let find_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.find",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_doc_find_1",
|
||||
"traceId": "trace_doc_find_1",
|
||||
"capabilityScope": ["page.read"],
|
||||
"args": {"query": "章节一"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(find_response.status(), StatusCode::OK);
|
||||
let find_body = to_bytes(find_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let find_payload: Value = serde_json::from_slice(&find_body).expect("json");
|
||||
assert_eq!(
|
||||
find_payload["result"]["matches"][0]["blockId"],
|
||||
json!("heading_1")
|
||||
);
|
||||
|
||||
let fetch_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.fetch",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_block_fetch_1",
|
||||
"traceId": "trace_block_fetch_1",
|
||||
"capabilityScope": ["block.read"],
|
||||
"args": {"blockId": "heading_1", "contextBefore": 1, "contextAfter": 1}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(fetch_response.status(), StatusCode::OK);
|
||||
let fetch_body = to_bytes(fetch_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("json");
|
||||
assert_eq!(
|
||||
fetch_payload["result"]["block"]["blockId"],
|
||||
json!("heading_1")
|
||||
);
|
||||
assert_eq!(fetch_payload["result"]["block"]["text"], json!("章节一"));
|
||||
assert_eq!(fetch_payload["audit"]["effect"], "read");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_plan_update_and_block_move_after_are_dry_run_only() {
|
||||
let heading_ref = block_revision_ref("heading_1").await;
|
||||
let plan_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.plan_update",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_plan_1",
|
||||
"traceId": "trace_plan_1",
|
||||
"idempotencyKey": "idem_plan_1",
|
||||
"dryRun": true,
|
||||
"capabilityScope": ["page.write"],
|
||||
"args": {
|
||||
"command": "block_replace",
|
||||
"blockId": "heading_1",
|
||||
"content": "替换标题"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(plan_response.status(), StatusCode::OK);
|
||||
let plan_body = to_bytes(plan_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let plan_payload: Value = serde_json::from_slice(&plan_body).expect("json");
|
||||
assert_eq!(plan_payload["audit"]["effect"], "dry_run");
|
||||
assert_eq!(plan_payload["result"]["diff"][0]["op"], "replace");
|
||||
|
||||
let move_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.move_after",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_move_1",
|
||||
"traceId": "trace_move_1",
|
||||
"idempotencyKey": "idem_move_1",
|
||||
"dryRun": false,
|
||||
"capabilityScope": ["block.write"],
|
||||
"args": {
|
||||
"blockId": "heading_1",
|
||||
"anchorBlockId": "heading_1",
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7",
|
||||
"blockRevisionRef": heading_ref.clone(),
|
||||
"anchorRevisionRef": heading_ref.clone()
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(move_response.status(), StatusCode::OK);
|
||||
let move_body = to_bytes(move_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
|
||||
assert_eq!(move_payload["result"]["blocked"], true);
|
||||
assert_eq!(
|
||||
move_payload["result"]["warnings"][0]["code"],
|
||||
"block_move_after_blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_block_replace_and_insert_after_write_through_page_body_save() {
|
||||
let heading_ref = block_revision_ref("heading_1").await;
|
||||
let p1_ref = block_revision_ref("p_1").await;
|
||||
let p2_ref = block_revision_ref("p_2").await;
|
||||
let replace_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.replace",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_replace_1",
|
||||
"traceId": "trace_replace_1",
|
||||
"idempotencyKey": "idem_replace_1",
|
||||
"dryRun": false,
|
||||
"capabilityScope": ["block.write"],
|
||||
"args": {
|
||||
"blockId": "heading_1",
|
||||
"content": "替换后的章节",
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7",
|
||||
"blockRevisionRef": heading_ref.clone()
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(replace_response.status(), StatusCode::OK);
|
||||
let replace_body = to_bytes(replace_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let replace_payload: Value = serde_json::from_slice(&replace_body).expect("json");
|
||||
assert_eq!(replace_payload["audit"]["effect"], "write");
|
||||
assert_eq!(
|
||||
replace_payload["result"]["changedBlocks"][0]["blockId"],
|
||||
json!("heading_1")
|
||||
);
|
||||
assert_eq!(
|
||||
replace_payload["result"]["commandName"],
|
||||
json!("page.body.save")
|
||||
);
|
||||
|
||||
let insert_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.insert_after",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_insert_1",
|
||||
"traceId": "trace_insert_1",
|
||||
"idempotencyKey": "idem_insert_1",
|
||||
"dryRun": false,
|
||||
"capabilityScope": ["block.write"],
|
||||
"args": {
|
||||
"anchorBlockId": "heading_1",
|
||||
"content": "新增段落",
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7",
|
||||
"anchorRevisionRef": heading_ref.clone()
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(insert_response.status(), StatusCode::OK);
|
||||
let insert_body = to_bytes(insert_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let insert_payload: Value = serde_json::from_slice(&insert_body).expect("json");
|
||||
assert_eq!(insert_payload["audit"]["effect"], "write");
|
||||
assert_eq!(
|
||||
insert_payload["result"]["changedBlocks"][0]["op"],
|
||||
json!("insert_after")
|
||||
);
|
||||
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("ai_block_"));
|
||||
|
||||
let delete_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.delete",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_delete_1",
|
||||
"traceId": "trace_delete_1",
|
||||
"idempotencyKey": "idem_delete_1",
|
||||
"dryRun": false,
|
||||
"capabilityScope": ["block.write"],
|
||||
"args": {
|
||||
"blockId": "p_1",
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7",
|
||||
"blockRevisionRef": p1_ref.clone()
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||
let delete_body = to_bytes(delete_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("json");
|
||||
assert_eq!(delete_payload["audit"]["effect"], "write");
|
||||
assert_eq!(
|
||||
delete_payload["result"]["changedBlocks"][0]["op"],
|
||||
json!("delete")
|
||||
);
|
||||
assert_eq!(
|
||||
delete_payload["result"]["changedBlocks"][0]["blockId"],
|
||||
json!("p_1")
|
||||
);
|
||||
|
||||
let move_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.move_after",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_move_write_1",
|
||||
"traceId": "trace_move_write_1",
|
||||
"idempotencyKey": "idem_move_write_1",
|
||||
"dryRun": false,
|
||||
"capabilityScope": ["block.write"],
|
||||
"args": {
|
||||
"blockId": "heading_1",
|
||||
"anchorBlockId": "p_2",
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7",
|
||||
"blockRevisionRef": heading_ref.clone(),
|
||||
"anchorRevisionRef": p2_ref.clone()
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(move_response.status(), StatusCode::OK);
|
||||
let move_body = to_bytes(move_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let move_payload: Value = serde_json::from_slice(&move_body).expect("json");
|
||||
assert_eq!(move_payload["audit"]["effect"], "write");
|
||||
assert_eq!(
|
||||
move_payload["result"]["changedBlocks"][0]["op"],
|
||||
json!("move_after")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_block_write_requires_fresh_revision_and_block_ref() {
|
||||
let missing_revision_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.replace",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_missing_revision_1",
|
||||
"traceId": "trace_missing_revision_1",
|
||||
"idempotencyKey": "idem_missing_revision_1",
|
||||
"dryRun": false,
|
||||
"capabilityScope": ["block.write"],
|
||||
"args": {
|
||||
"blockId": "heading_1",
|
||||
"content": "不应写入"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(missing_revision_response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
missing_revision_response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_write_precondition_required")
|
||||
);
|
||||
|
||||
let stale_ref_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/hermes/tools/mnote/call")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.block.replace",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_1",
|
||||
"runId": "run_1",
|
||||
"toolCallId": "call_stale_ref_1",
|
||||
"traceId": "trace_stale_ref_1",
|
||||
"idempotencyKey": "idem_stale_ref_1",
|
||||
"dryRun": false,
|
||||
"capabilityScope": ["block.write"],
|
||||
"args": {
|
||||
"blockId": "heading_1",
|
||||
"content": "不应写入",
|
||||
"revision": 7,
|
||||
"conflictDetectionKey": "doc_1:7",
|
||||
"blockRevisionRef": "pageRev:old:block:heading_1:hash:stale"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(stale_ref_response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
stale_ref_response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_tool_conflict")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_reject_workspace_context_conflict_without_content_leak() {
|
||||
let response = app()
|
||||
|
||||
@@ -203,6 +203,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::routes::local_markdown_parser::{
|
||||
};
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::http::StatusCode;
|
||||
use bridge_runtime::project_legacy_content_to_block_document;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::cmp::Ordering;
|
||||
@@ -306,6 +307,10 @@ pub fn resolve_local_markdown_page_aggregate(
|
||||
.count() as u64;
|
||||
let read_only = markdown_file.is_readonly;
|
||||
|
||||
let block_document =
|
||||
project_legacy_content_to_block_document(document_id, &content, &Value::Number(0.into()))
|
||||
.map_err(|error| WebError::internal(format!("{error:?}")))?;
|
||||
|
||||
Ok(PageAggregate {
|
||||
schema: PageAggregate::SCHEMA.into(),
|
||||
projection_version: PageAggregate::VERSION,
|
||||
@@ -339,6 +344,9 @@ pub fn resolve_local_markdown_page_aggregate(
|
||||
document_id,
|
||||
&markdown_file.path,
|
||||
)?),
|
||||
block_document,
|
||||
block_projection_version: 1,
|
||||
projection_source: "local_markdown.content".into(),
|
||||
},
|
||||
tree: PageTree { page_subtree },
|
||||
stats: PageStats {
|
||||
|
||||
@@ -334,6 +334,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -283,6 +283,7 @@ mod tests {
|
||||
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,
|
||||
@@ -350,6 +351,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -16,6 +16,7 @@ mod media;
|
||||
mod mindmap_api;
|
||||
mod mindmap_shell;
|
||||
mod onlyoffice;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
mod resource_trash;
|
||||
mod search;
|
||||
@@ -86,6 +87,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
|
||||
.route(
|
||||
"/api/page-ai/block-edit-workflow",
|
||||
post(page_ai_workflow::block_edit_workflow),
|
||||
)
|
||||
.route("/onlyoffice", get(onlyoffice::page))
|
||||
.route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy))
|
||||
.route("/cache/{*path}", any(onlyoffice::cache_proxy))
|
||||
@@ -196,6 +201,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/client/skills", get(hermes_client::list_skills))
|
||||
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
|
||||
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
|
||||
.route("/client/runs", post(hermes_client::create_run))
|
||||
.route(
|
||||
"/client/sessions/{session_id}/queue/{queue_id}",
|
||||
@@ -244,6 +250,7 @@ mod tests {
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
|
||||
@@ -1132,6 +1132,7 @@ mod tests {
|
||||
legacy_next_base_url,
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{block, 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();
|
||||
let (operations, operation_source) = if let Some(operations) =
|
||||
direct_block_edit_operations(&message)
|
||||
{
|
||||
(operations, "local_rule")
|
||||
} else {
|
||||
let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?;
|
||||
(extract_operations_from_model_text(&model_output)?, "model")
|
||||
};
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
run_id = %run_id,
|
||||
operations = operations.len(),
|
||||
operation_source = operation_source,
|
||||
model_ms = model_started.elapsed().as_millis(),
|
||||
"mnote page AI block workflow model completed"
|
||||
);
|
||||
if operations.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_ai_workflow_empty_operations",
|
||||
"模型未返回块操作",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let allowed_target_block_ids = ai_context
|
||||
.get("allowedTargetBlockIds")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]));
|
||||
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 apply_input = ToolCallInput {
|
||||
tool_name: "mnote.doc.apply_block_ops".into(),
|
||||
workspace_id: Some(workspace_id.clone()),
|
||||
document_id: Some(document_id.clone()),
|
||||
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_apply_{}", context.trace.request_id)),
|
||||
trace_id: Some(trace_id.clone()),
|
||||
idempotency_key: Some(format!("page_ai_fast_apply_{}", context.trace.request_id)),
|
||||
dry_run: Some(false),
|
||||
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
||||
args: Some(json!({
|
||||
"operations": operations,
|
||||
"allowedTargetBlockIds": allowed_target_block_ids
|
||||
})),
|
||||
};
|
||||
let apply_started = Instant::now();
|
||||
let apply_result = block::doc_apply_block_ops(&state, &context, &apply_input).await?;
|
||||
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,
|
||||
"operationSource": operation_source,
|
||||
"operations": apply_input.arg_value("operations").unwrap_or_else(|| json!([])),
|
||||
"applyResult": apply_result,
|
||||
"message": "已通过页面块编辑快路径完成写入。",
|
||||
"timingsMs": {
|
||||
"total": started.elapsed().as_millis(),
|
||||
"apply": apply_ms
|
||||
}
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
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 = 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\":\"...\"}。operations 的 op 只能是 replace、insert_after、delete、move_after。优先使用 page_xml 中的 block id;禁止输出解释文字。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": format!(
|
||||
"用户指令:{}\n\nallowedTargetBlockIds:{}\n\npage_xml:\n{}\n\npage_text:\n{}",
|
||||
message,
|
||||
allowed,
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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};
|
||||
|
||||
#[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"], "第三段");
|
||||
}
|
||||
}
|
||||
@@ -1056,6 +1056,7 @@ mod tests {
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
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,
|
||||
|
||||
@@ -376,6 +376,7 @@ mod tests {
|
||||
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,
|
||||
@@ -493,6 +494,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -130,6 +130,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -19,6 +19,15 @@ pub async fn events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<StreamSnapshotQuery>,
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
|
||||
events_with_block_delta(state, context, query, None).await
|
||||
}
|
||||
|
||||
async fn events_with_block_delta(
|
||||
state: AppState,
|
||||
context: RequestContext,
|
||||
query: StreamSnapshotQuery,
|
||||
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, WebError> {
|
||||
let initial_payload = load_stream_snapshot(state.config(), &context, &query).await?;
|
||||
let initial_cursor = read_stream_cursor_from_payload(&initial_payload);
|
||||
@@ -36,6 +45,7 @@ pub async fn events(
|
||||
polls: 0,
|
||||
initial_payload,
|
||||
initial_emitted: false,
|
||||
block_delta_rx,
|
||||
}),
|
||||
move |state| async move {
|
||||
let mut state = state?;
|
||||
@@ -48,6 +58,23 @@ pub async fn events(
|
||||
));
|
||||
}
|
||||
|
||||
// Phase C:在每次 poll 前先检查是否有 block.delta 可发送
|
||||
if let Some(ref mut rx) = state.block_delta_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(payload) => {
|
||||
return Some((
|
||||
Ok(stream_event("block.delta", &payload)),
|
||||
Some(state),
|
||||
));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
|
||||
state.block_delta_rx = None;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
if let Some(max_polls) = max_polls {
|
||||
if state.polls >= max_polls {
|
||||
@@ -57,6 +84,23 @@ pub async fn events(
|
||||
state.polls += 1;
|
||||
sleep(Duration::from_millis(poll_ms)).await;
|
||||
|
||||
// 每次 poll 后也检查一下 delta
|
||||
if let Some(ref mut rx) = state.block_delta_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(payload) => {
|
||||
return Some((
|
||||
Ok(stream_event("block.delta", &payload)),
|
||||
Some(state),
|
||||
));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Closed) => {
|
||||
state.block_delta_rx = None;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let poll_query = live_poll_query(&state.query);
|
||||
let Ok((workspace_id, overview)) =
|
||||
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
|
||||
@@ -140,11 +184,11 @@ pub async fn tree_events(
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-tree-stream-owner") {
|
||||
headers.insert(name, HeaderValue::from_static("rust-web"));
|
||||
}
|
||||
let sse = events(State(state), Extension(context), Query(query)).await?;
|
||||
let block_delta_rx = state.block_delta_tx.subscribe();
|
||||
let sse = events_with_block_delta(state, context, query, Some(block_delta_rx)).await?;
|
||||
Ok((headers, sse))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StreamPollState {
|
||||
app_state: AppState,
|
||||
context: RequestContext,
|
||||
@@ -153,6 +197,8 @@ struct StreamPollState {
|
||||
polls: u32,
|
||||
initial_payload: Value,
|
||||
initial_emitted: bool,
|
||||
#[allow(dead_code)]
|
||||
block_delta_rx: Option<tokio::sync::broadcast::Receiver<Value>>,
|
||||
}
|
||||
|
||||
fn live_poll_query(query: &StreamSnapshotQuery) -> StreamSnapshotQuery {
|
||||
@@ -203,6 +249,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -1034,6 +1034,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_preserves_remove_asset_delta_fields() {
|
||||
let overview = json!({
|
||||
"command_logs": [
|
||||
{
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.resource.delete",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "remove_asset",
|
||||
"assetId": "asset_1",
|
||||
"documentId": "doc_target",
|
||||
"updatedAt": "2026-04-25T10:00:02Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clog_1",
|
||||
"command_id": "cmd_1",
|
||||
"created_at": "2026-04-25T10:00:01Z"
|
||||
}
|
||||
],
|
||||
"domain_events": []
|
||||
});
|
||||
|
||||
let change = resolve_stream_change(
|
||||
&overview,
|
||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||
)
|
||||
.expect("应识别到变化");
|
||||
|
||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||
assert_eq!(
|
||||
change.delta,
|
||||
Some(json!({
|
||||
"op": "remove_asset",
|
||||
"assetId": "asset_1",
|
||||
"documentId": "doc_target",
|
||||
"updatedAt": "2026-04-25T10:00:02Z"
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
|
||||
let overview = json!({
|
||||
@@ -1395,5 +1440,9 @@ mod tests {
|
||||
{ "id": "asset_1" }
|
||||
]
|
||||
})));
|
||||
assert!(delta_requires_projection_snapshot(&json!({
|
||||
"op": "remove_asset",
|
||||
"assetId": "asset_1"
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6940,6 +6940,7 @@ mod tests {
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: true,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
@@ -7896,6 +7897,7 @@ mod tests {
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_child".into())
|
||||
);
|
||||
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
|
||||
assert_eq!(
|
||||
payload["result"]["execution"]["deletedCount"],
|
||||
Value::from(1)
|
||||
@@ -8002,10 +8004,18 @@ mod tests {
|
||||
payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
|
||||
Value::String("move_document".into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["sortOrder"],
|
||||
Value::from(1)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["op"],
|
||||
Value::String("move_document".into())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["artifacts"]["commandLog"]["payload"]["streamDelta"]["sortOrder"],
|
||||
Value::from(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2936,6 +2936,7 @@ mod tests {
|
||||
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,
|
||||
@@ -2969,6 +2970,29 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
fn app_with_unreachable_convex_without_fixture() -> 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: None,
|
||||
enable_legacy_next_compat: false,
|
||||
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: Some("http://127.0.0.1:9".into()),
|
||||
convex_admin_key: Some("test-admin-key".into()),
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_returns_page_aggregate_snapshot() {
|
||||
let response = app()
|
||||
@@ -3100,6 +3124,100 @@ mod tests {
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_errors_without_convex_or_fixture() {
|
||||
let response = app_with_unreachable_convex_without_fixture()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/page-aggregate/doc_1?workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
panic!(
|
||||
"expected SERVICE_UNAVAILABLE, got {status}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("convex_unavailable")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-phase")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("query_send")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-upstream-service")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("convex")
|
||||
);
|
||||
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"], false);
|
||||
assert_eq!(payload["code"], "convex_unavailable");
|
||||
assert!(payload.get("schema").is_none());
|
||||
assert!(payload.get("result").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_errors_without_convex_or_fixture() {
|
||||
let response = app_with_unreachable_convex_without_fixture()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/documents/doc_1?workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
if response.status() != StatusCode::SERVICE_UNAVAILABLE {
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
panic!(
|
||||
"expected SERVICE_UNAVAILABLE, got {status}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("convex_unavailable")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "convex_unavailable");
|
||||
assert!(!text.contains("mnote.page_aggregate.v1"));
|
||||
assert!(!text.contains("data-mnote-dev-fixture"));
|
||||
assert!(!text.contains("data-page-aggregate-snapshot"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
|
||||
let root =
|
||||
@@ -3231,6 +3349,7 @@ mod tests {
|
||||
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,
|
||||
|
||||
@@ -50,7 +50,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiGatewayHealthError: '',
|
||||
pageAiLastToolCall: null,
|
||||
pageAiProfiles: [],
|
||||
pageAiActiveProfileName: 'default',
|
||||
pageAiActiveProfileName: 'mnoteai',
|
||||
pageAiProfileError: '',
|
||||
pageAiProfileMemory: { memory: '', user: '', soul: '' },
|
||||
pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' },
|
||||
@@ -256,6 +256,83 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiProjectionBlocks(aggregate) {
|
||||
var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks;
|
||||
return Array.isArray(blocks) ? blocks : [];
|
||||
}
|
||||
|
||||
function pageAiBlockText(block) {
|
||||
return searchText(block && (block.text || block.title || block.content) || '');
|
||||
}
|
||||
|
||||
function pageAiSelectedBlockIdsFromSelection() {
|
||||
try {
|
||||
var selection = window.getSelection ? window.getSelection() : null;
|
||||
if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return [];
|
||||
var range = selection.getRangeAt(0);
|
||||
var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return [];
|
||||
return Array.from(editor.children).filter(function(node) {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
try {
|
||||
return range.intersectsNode(node);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).map(function(node) {
|
||||
return searchText(node.getAttribute('data-id') || node.id || '');
|
||||
}).filter(Boolean);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiBlocksToPageXml(blocks, aggregate) {
|
||||
var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : '';
|
||||
var pageId = currentDocumentId() || 'current-page';
|
||||
var lines = ['<page id="' + escapeHtml(pageId) + '" revision="' + escapeHtml(revision) + '">'];
|
||||
blocks.forEach(function(block) {
|
||||
var blockId = String(block && (block.blockId || block.id) || '');
|
||||
var type = String(block && block.type || 'paragraph');
|
||||
var revisionRef = String(block && block.revisionRef || '');
|
||||
var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : '';
|
||||
lines.push(' <block id="' + escapeHtml(blockId) + '" type="' + escapeHtml(type) + '" revisionRef="' + escapeHtml(revisionRef) + '"' + level + '>' + escapeHtml(pageAiBlockText(block)) + '</block>');
|
||||
});
|
||||
lines.push('</page>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPageAiContext(contextSnapshot, scope, selectedText) {
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = aggregate.body || {};
|
||||
var allBlocks = pageAiProjectionBlocks(aggregate);
|
||||
var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : [];
|
||||
var selectedSet = {};
|
||||
selectedBlockIds.forEach(function(id) { selectedSet[id] = true; });
|
||||
var selectedBlocks = selectedBlockIds.length
|
||||
? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; })
|
||||
: [];
|
||||
var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120);
|
||||
var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length;
|
||||
return {
|
||||
schema: 'mnote.page_ai_context.v1',
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
scope: scope,
|
||||
revision: body.revision || null,
|
||||
conflictDetectionKey: body.conflictDetectionKey || null,
|
||||
selectedText: selectedText || '',
|
||||
selectedBlockIds: selectedBlockIds,
|
||||
allowedTargetBlockIds: selectedBlockIds,
|
||||
selectedBlocks: selectedBlocks,
|
||||
contextBlocks: contextBlocks,
|
||||
pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'),
|
||||
pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate),
|
||||
truncated: truncated,
|
||||
warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : []
|
||||
};
|
||||
}
|
||||
|
||||
function pageAiScopedPageContext(contextSnapshot) {
|
||||
var aggregate = contextSnapshot.aggregate || {};
|
||||
var body = contextSnapshot.body || {};
|
||||
@@ -264,6 +341,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var scope = pageUiState.pageAiContextScope || 'page';
|
||||
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
|
||||
var selectedText = scope === 'selection' ? currentPageAiSelectedText() : '';
|
||||
var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText);
|
||||
return {
|
||||
pageContext: {
|
||||
contextScope: scope,
|
||||
@@ -277,10 +355,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope,
|
||||
evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null,
|
||||
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null,
|
||||
contentAccess: 'mnote.page.get'
|
||||
contentAccess: 'mnote.doc.fetch',
|
||||
aiContext: aiContext
|
||||
},
|
||||
selectedText: selectedText,
|
||||
selectedBlockId: null
|
||||
selectedBlockId: aiContext.selectedBlockIds[0] || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1160,6 +1239,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (!documentId) return;
|
||||
var escaped = cssEscape(documentId);
|
||||
var escapedDocRowId = cssEscape('doc:' + documentId);
|
||||
var isCurrentDocument = currentDocumentId() === documentId;
|
||||
var pageSelectors = [
|
||||
'.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
|
||||
'.wolai-page-row[data-node-id="' + escaped + '"] > .wolai-row-title',
|
||||
@@ -1174,6 +1254,22 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
document.querySelectorAll('.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title').forEach(function(node) {
|
||||
if (node instanceof HTMLElement) node.textContent = fileTreePageTitle(title);
|
||||
});
|
||||
document.querySelectorAll('[data-page-title-input="true"][data-document-id="' + escaped + '"]').forEach(function(node) {
|
||||
if (!(node instanceof HTMLTextAreaElement)) return;
|
||||
node.value = title;
|
||||
node.setAttribute('data-title-last-saved', title);
|
||||
node.setAttribute('data-title-save-status', 'saved');
|
||||
node.style.height = 'auto';
|
||||
node.style.height = Math.max(48, node.scrollHeight) + 'px';
|
||||
});
|
||||
document.querySelectorAll('[data-document-pane="true"][data-pane-document-id="' + escaped + '"] [data-page-title-current="true"]').forEach(function(node) {
|
||||
if (node instanceof HTMLElement) node.textContent = title;
|
||||
});
|
||||
if (isCurrentDocument) {
|
||||
document.title = title;
|
||||
var topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
||||
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreePageTitle(title) {
|
||||
@@ -1396,7 +1492,29 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return pageChanged || fileChanged;
|
||||
}
|
||||
|
||||
function moveDocumentRowForMode(mode, documentId, parentId) {
|
||||
function sortOrderFromDelta(data) {
|
||||
var raw = data && (data.sortOrder ?? data.sort_order);
|
||||
var value = Number(raw);
|
||||
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
||||
}
|
||||
|
||||
function insertTreeNodeAtSortOrder(targetContainer, node, sortOrder) {
|
||||
if (!targetContainer || !node) return false;
|
||||
if (sortOrder === null || sortOrder === undefined) {
|
||||
targetContainer.appendChild(node);
|
||||
return true;
|
||||
}
|
||||
var siblings = Array.from(targetContainer.querySelectorAll(':scope > .tree-node')).filter(function(candidate) {
|
||||
return candidate !== node;
|
||||
});
|
||||
var targetIndex = Math.max(0, Math.min(Number(sortOrder), siblings.length));
|
||||
var referenceNode = siblings[targetIndex] || null;
|
||||
if (referenceNode) targetContainer.insertBefore(node, referenceNode);
|
||||
else targetContainer.appendChild(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveDocumentRowForMode(mode, documentId, parentId, sortOrder) {
|
||||
var row = document.querySelector(rowSelectorForDocument(mode, documentId));
|
||||
var node = row ? row.closest('.tree-node') : null;
|
||||
var root = treeRootForMode(mode);
|
||||
@@ -1410,16 +1528,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
} else {
|
||||
row.removeAttribute('data-parent-id');
|
||||
}
|
||||
targetContainer.appendChild(node);
|
||||
return true;
|
||||
return insertTreeNodeAtSortOrder(targetContainer, node, sortOrder);
|
||||
}
|
||||
|
||||
function applyMoveDocumentDelta(data) {
|
||||
var documentId = documentIdFromDelta(data);
|
||||
if (!documentId) return false;
|
||||
var parentId = parentIdFromDelta(data);
|
||||
var movedPage = moveDocumentRowForMode('page', documentId, parentId);
|
||||
var movedFile = moveDocumentRowForMode('filetree', documentId, parentId);
|
||||
var sortOrder = sortOrderFromDelta(data);
|
||||
var movedPage = moveDocumentRowForMode('page', documentId, parentId, sortOrder);
|
||||
var movedFile = moveDocumentRowForMode('filetree', documentId, parentId, sortOrder);
|
||||
return movedPage || movedFile;
|
||||
}
|
||||
|
||||
@@ -3994,7 +4112,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var selected = pageUiState.pageAiProfiles.find(function(profile) {
|
||||
return profile && profile.active;
|
||||
});
|
||||
return pageAiProfileValue(selected) || 'default';
|
||||
return pageAiProfileValue(selected) || 'mnoteai';
|
||||
}
|
||||
|
||||
function pageAiMnoteToolModel() {
|
||||
return String(document.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash';
|
||||
}
|
||||
|
||||
function pageAiCurrentProfileRecord() {
|
||||
@@ -4006,10 +4128,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function pageAiCurrentModelLabel() {
|
||||
var profile = pageAiCurrentProfileRecord();
|
||||
if (!profile) return '由 Hermes 决定';
|
||||
var toolModel = pageAiMnoteToolModel();
|
||||
if (!profile) return 'tool: ' + toolModel;
|
||||
var model = String(profile.model || '').trim();
|
||||
var gateway = String(profile.gateway || '').trim();
|
||||
return [model, gateway].filter(Boolean).join(' · ') || '由 Hermes 决定';
|
||||
var profileLabel = [model, gateway].filter(Boolean).join(' · ') || '由 Hermes 决定';
|
||||
return 'tool: ' + toolModel + ' · profile: ' + profileLabel;
|
||||
}
|
||||
|
||||
function pageAiNormalizeProfiles(payload) {
|
||||
@@ -4085,9 +4209,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function pageAiSetActiveProfile(profileName) {
|
||||
var next = String(profileName || '').trim() || 'default';
|
||||
var next = String(profileName || '').trim() || 'mnoteai';
|
||||
pageUiState.pageAiActiveProfileName = next;
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-profile', next);
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-tool-model', pageAiMnoteToolModel());
|
||||
}
|
||||
|
||||
function pageAiSetRunStatus(status, runId) {
|
||||
@@ -4186,10 +4311,20 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var writesCurrentPage = [
|
||||
'mnote.page.save',
|
||||
'mnote.page.update.title',
|
||||
'mnote.page.update.options'
|
||||
'mnote.page.update.options',
|
||||
'mnote.doc.apply.block.ops',
|
||||
'mnote.block.replace',
|
||||
'mnote.block.insert.after',
|
||||
'mnote.block.delete',
|
||||
'mnote.block.move.after'
|
||||
].indexOf(normalizedTool) >= 0 || [
|
||||
'mnote.page.update_title',
|
||||
'mnote.page.update_options'
|
||||
'mnote.page.update_options',
|
||||
'mnote.doc.apply_block_ops',
|
||||
'mnote.block.replace',
|
||||
'mnote.block.insert_after',
|
||||
'mnote.block.delete',
|
||||
'mnote.block.move_after'
|
||||
].indexOf(String(toolName || '').trim()) >= 0;
|
||||
if (!writesCurrentPage) return;
|
||||
var documentId = pageAiToolEventDeepFindString(toolEvent, ['documentId', 'document_id'], 0) || currentDocumentId();
|
||||
@@ -4352,6 +4487,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
scope: String(tool && (tool.scope || tool.requiredScope || '') || '').trim(),
|
||||
kind: String(tool && (tool.kind || tool.mode || '') || '').trim(),
|
||||
status: String(tool && (tool.status || tool.permission || 'available') || '').trim(),
|
||||
enabled: tool && tool.enabled !== false,
|
||||
unavailableReason: String(tool && (tool.unavailableReason || tool.reason || '') || '').trim()
|
||||
};
|
||||
}).filter(Boolean);
|
||||
@@ -4394,7 +4530,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
async function pageAiLoadTools() {
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/tools?scope=mnote', {
|
||||
var response = await fetch('/api/hermes/client/tools?scope=mnote&profile=' + encodeURIComponent(pageAiCurrentProfile()), {
|
||||
headers: { 'accept': 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
@@ -4483,14 +4619,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profiles_failed_' + response.status));
|
||||
var profiles = pageAiNormalizeProfiles(payload);
|
||||
pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'default', active: true }];
|
||||
pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'mnoteai', active: true }];
|
||||
var current = pageAiCurrentProfile();
|
||||
var hasCurrent = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === current; });
|
||||
var hasMnoteAi = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === 'mnoteai'; });
|
||||
var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; }));
|
||||
pageAiSetActiveProfile(active || pageAiCurrentProfile());
|
||||
pageAiSetActiveProfile(hasCurrent && current !== 'default' ? current : (hasMnoteAi ? 'mnoteai' : (active || current)));
|
||||
pageUiState.pageAiProfileError = '';
|
||||
void pageAiLoadGatewayHealth();
|
||||
} catch (error) {
|
||||
pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error);
|
||||
if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'default', active: true }];
|
||||
if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'mnoteai', active: true }];
|
||||
pageAiSetActiveProfile(pageAiCurrentProfile());
|
||||
}
|
||||
renderPageAiProviderButtons();
|
||||
@@ -4520,6 +4659,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
await pageAiEnsureHermesSession(true);
|
||||
await pageAiLoadProfileMemory();
|
||||
await pageAiLoadSkills();
|
||||
await pageAiLoadTools();
|
||||
await pageAiLoadGatewayHealth();
|
||||
renderPageAiControls();
|
||||
} catch (error) {
|
||||
@@ -4627,6 +4767,45 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
renderPageAiConversation();
|
||||
}
|
||||
|
||||
async function pageAiToggleTool(toolName, enabled) {
|
||||
var name = String(toolName || '').trim();
|
||||
if (!name) return;
|
||||
var previous = null;
|
||||
pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) {
|
||||
if (tool.name === name && previous == null) previous = tool.enabled !== false;
|
||||
});
|
||||
try {
|
||||
var response = await fetch('/api/hermes/client/tools/toggle', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile: pageAiCurrentProfile(),
|
||||
name: name,
|
||||
enabled: Boolean(enabled)
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tool_toggle_failed_' + response.status));
|
||||
pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) {
|
||||
if (tool.name === name) {
|
||||
tool.enabled = Boolean(enabled);
|
||||
tool.status = Boolean(enabled) ? 'available' : 'disabled';
|
||||
tool.unavailableReason = Boolean(enabled) ? '' : '当前 Hermes profile 已关闭该 mnote tool';
|
||||
}
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-tool-toggled', name);
|
||||
pageUiState.pageAiToolsError = '';
|
||||
} catch (error) {
|
||||
pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error);
|
||||
if (previous != null) {
|
||||
pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) {
|
||||
if (tool.name === name) tool.enabled = previous;
|
||||
});
|
||||
}
|
||||
}
|
||||
renderPageAiControls();
|
||||
}
|
||||
|
||||
function renderPageAiProviderButtons() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) {
|
||||
@@ -4785,9 +4964,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
toolsList.innerHTML = tools.map(function(tool) {
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-tool-row">' +
|
||||
'<div class="wolai-page-ai-tool-name">' + escapeHtml(tool.name) + '</div>' +
|
||||
'<div class="wolai-page-ai-tool-meta">' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '</div>' +
|
||||
(tool.unavailableReason ? '<div class="wolai-page-ai-tool-meta">' + escapeHtml(tool.unavailableReason) + '</div>' : '') +
|
||||
'<div class="wolai-page-ai-skill-copy">' +
|
||||
'<div class="wolai-page-ai-tool-name">' + escapeHtml(tool.name) + '</div>' +
|
||||
'<div class="wolai-page-ai-tool-meta">' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '</div>' +
|
||||
(tool.unavailableReason ? '<div class="wolai-page-ai-tool-meta">' + escapeHtml(tool.unavailableReason) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<button type="button" class="wolai-page-ai-skill-switch' + (tool.enabled !== false ? ' is-on' : '') + '" data-page-ai-tool-toggle="' + escapeHtml(tool.name) + '" aria-pressed="' + (tool.enabled !== false ? 'true' : 'false') + '">' +
|
||||
'<span></span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -5161,6 +5345,88 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiLooksLikeBlockEdit(prompt) {
|
||||
var text = searchText(prompt);
|
||||
return ['新增', '添加', '插入', '删除', '删掉', '修改', '替换', '改成', '移动', '移到', 'move', 'replace', 'delete', 'insert'].some(function(word) {
|
||||
return text.indexOf(word) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function pageAiTryBlockEditWorkflow(prompt, scopedContext) {
|
||||
if (!pageAiLooksLikeBlockEdit(prompt)) return false;
|
||||
var runId = 'page-ai-fast-' + Date.now().toString(36);
|
||||
var traceId = 'page-ai-fast-' + Date.now().toString(36);
|
||||
pageAiSetRunStatus('running', runId);
|
||||
renderPageAiControls();
|
||||
var started = Date.now();
|
||||
var response = await fetch('/api/page-ai/block-edit-workflow', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
runId: runId,
|
||||
profile: pageAiCurrentProfile(),
|
||||
model: pageAiMnoteToolModel(),
|
||||
message: prompt,
|
||||
pageContext: scopedContext.pageContext,
|
||||
selectedBlockId: scopedContext.selectedBlockId,
|
||||
selectedText: scopedContext.selectedText,
|
||||
traceId: traceId
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
var code = payload && payload.code ? String(payload.code) : '';
|
||||
if (code === 'page_ai_workflow_not_block_edit') return false;
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'tool',
|
||||
toolName: 'mnote.page_ai.block_edit_workflow',
|
||||
status: 'failed',
|
||||
toolCallId: runId,
|
||||
resultSummary: pageAiErrorMessage(payload, 'fast_path_failed_' + response.status)
|
||||
});
|
||||
renderPageAiConversation();
|
||||
pageAiSetRunStatus('failed', runId);
|
||||
renderPageAiControls();
|
||||
return true;
|
||||
}
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'tool',
|
||||
toolName: 'mnote.page_ai.block_edit_workflow',
|
||||
status: 'completed',
|
||||
toolCallId: runId,
|
||||
resultSummary: 'operations=' + String(Array.isArray(payload.operations) ? payload.operations.length : 0) + ' · ' + String(Date.now() - started) + 'ms'
|
||||
});
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'assistant',
|
||||
content: payload.message || '已通过页面块编辑快路径完成写入。'
|
||||
});
|
||||
pageAiSetRunStatus('completed', runId);
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', {
|
||||
detail: {
|
||||
toolName: 'mnote.doc.apply_block_ops',
|
||||
normalizedToolName: 'mnote.doc.apply.block.ops',
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
runId: runId,
|
||||
traceId: traceId,
|
||||
toolCallId: runId
|
||||
}
|
||||
}));
|
||||
} catch (_) {}
|
||||
var currentSession = pageAiCurrentSession();
|
||||
if (currentSession) {
|
||||
currentSession.messages = pageUiState.pageAiMessages.slice();
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
renderPageAiConversation();
|
||||
renderPageAiControls();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendPageAiMessage(text) {
|
||||
var allowQueue = ['running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0;
|
||||
if (pageUiState.pageAiBusy && !allowQueue) return;
|
||||
@@ -5184,6 +5450,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
currentSession.updatedAt = Date.now();
|
||||
}
|
||||
renderPageAiConversation();
|
||||
if (!allowQueue && await pageAiTryBlockEditWorkflow(prompt, scopedContext)) {
|
||||
return;
|
||||
}
|
||||
var response = await fetch('/api/hermes/client/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -5194,7 +5463,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
profile: pageAiCurrentProfile(),
|
||||
contextScope: pageUiState.pageAiContextScope,
|
||||
message: prompt,
|
||||
model: 'hermes-agent',
|
||||
model: pageAiMnoteToolModel(),
|
||||
pageContext: scopedContext.pageContext,
|
||||
selectedBlockId: scopedContext.selectedBlockId,
|
||||
selectedText: scopedContext.selectedText,
|
||||
@@ -5911,6 +6180,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiToolToggle = closestAction(e.target, '[data-page-ai-tool-toggle]');
|
||||
if (pageAiToolToggle) {
|
||||
e.preventDefault();
|
||||
var toolName = pageAiToolToggle.getAttribute('data-page-ai-tool-toggle') || '';
|
||||
var nextToolEnabled = pageAiToolToggle.getAttribute('aria-pressed') !== 'true';
|
||||
void pageAiToggleTool(toolName, nextToolEnabled);
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
|
||||
if (pageAiSession) {
|
||||
e.preventDefault();
|
||||
@@ -6269,7 +6547,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
window.__mnoteRecordPageHistorySnapshot = recordPageHistorySnapshot;
|
||||
window.__mnoteApplyPageOptionsToShell = applyPageOptionsToShell;
|
||||
setTimeout(initializePageUiSurfaces, 0);
|
||||
function scheduleInitializePageUiSurfaces() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(initializePageUiSurfaces, 0);
|
||||
}, { once: true });
|
||||
return;
|
||||
}
|
||||
setTimeout(initializePageUiSurfaces, 0);
|
||||
}
|
||||
scheduleInitializePageUiSurfaces();
|
||||
|
||||
function readPageDragNodeId(event) {
|
||||
var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : '';
|
||||
@@ -6441,7 +6728,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
if (action === 'move' && body.documentId) {
|
||||
if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null })) {
|
||||
if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move');
|
||||
}
|
||||
return;
|
||||
@@ -6799,6 +7086,11 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'remove"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'rename"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'move"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function sortOrderFromDelta(data)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data.sortOrder ?? data.sort_order"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function insertTreeNodeAtSortOrder"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null, sortOrder: body.sortOrder })"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("wolai:assets-changed"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("applyAssetsChangedToFileTree"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("installMindmapAssetFetchObserver"));
|
||||
@@ -6880,6 +7172,12 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#"[data-page-title-input="true"][data-document-id="' + escaped + '"]"#
|
||||
));
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
r#".wolai-breadcrumb-current [data-page-title-current]"#
|
||||
));
|
||||
assert!(!SIDEBAR_TREE_JS.contains(
|
||||
r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
|
||||
@@ -3154,11 +3154,15 @@ body {
|
||||
|
||||
.wolai-page-ai-tool-list {
|
||||
display: grid;
|
||||
max-height: 96px;
|
||||
max-height: 140px;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[data-page-ai-tool-list].wolai-page-ai-tool-list {
|
||||
max-height: min(420px, calc(100vh - 330px));
|
||||
}
|
||||
|
||||
.wolai-page-ai-tool-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
|
||||
@@ -417,11 +417,13 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
| "tree.node.rename"
|
||||
| "tree.node.archive"
|
||||
| "tree.node.restore"
|
||||
| "tree.node.purge"
|
||||
| "tree.subtree.move"
|
||||
| "documents.create"
|
||||
| "documents.title.update"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.purge"
|
||||
| "documents.move"
|
||||
) {
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
@@ -546,6 +548,7 @@ fn strip_tree_artifact_fields(args: &mut Value) {
|
||||
map.remove("domainEventHint");
|
||||
map.remove("domainEventPlan");
|
||||
map.remove("domainEventPlans");
|
||||
map.remove("commandProtocol");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -930,6 +933,7 @@ mod tests {
|
||||
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: Some("http://127.0.0.1:3210".into()),
|
||||
@@ -1084,14 +1088,15 @@ mod tests {
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
|
||||
"domainEventHint": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlan": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlans": [{"eventType": "tree.node.archived"}],
|
||||
}),
|
||||
};
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"streamDeltaHint": {"family": "tree", "kind": "remove_document"},
|
||||
"domainEventHint": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlan": {"eventType": "tree.node.archived"},
|
||||
"domainEventPlans": [{"eventType": "tree.node.archived"}],
|
||||
"commandProtocol": {"family": "tree"},
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
@@ -1103,6 +1108,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_tree_purge_artifacts_for_legacy_mutation() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "tree.node.purge".into(),
|
||||
command_id: "cmd_purge_1".into(),
|
||||
function_name: "documents:purge".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
request_id: "req_1".into(),
|
||||
trace_id: "trace_1".into(),
|
||||
actor_id: "actor_1".into(),
|
||||
idempotency_key: None,
|
||||
source: json!({}),
|
||||
payload_json: "{}".into(),
|
||||
args_json: json!({
|
||||
"id": "doc_1",
|
||||
"commandProtocol": {"family": "tree"},
|
||||
}),
|
||||
};
|
||||
|
||||
let args = convex_command_args_for_plan(&plan);
|
||||
|
||||
assert_eq!(args, json!({ "id": "doc_1" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_resource_lifecycle_args_keep_effective_user_id() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Blocking waiting for file lock on package cache
|
||||
Blocking waiting for file lock on package cache
|
||||
Blocking waiting for file lock on package cache
|
||||
Compiling mnote-web v0.1.0 (/mnt/Data1T/mnote/rust/crates/mnote-web)
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.69s
|
||||
Running `target/debug/mnote-web`
|
||||
2026-04-23T16:14:28.636856Z INFO mnote-web 最小骨架已启动 bind_addr=127.0.0.1:3104
|
||||
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.89.0"
|
||||
channel = "stable"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
|
||||
@@ -42,6 +42,7 @@ const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state";
|
||||
const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status";
|
||||
const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection";
|
||||
const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command";
|
||||
const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta";
|
||||
const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height";
|
||||
const MINDMAP_SHELL_ACTION_EVENT: &str = "mnote:mindmap-shell:action";
|
||||
const MINDMAP_SHELL_PANEL_EVENT: &str = "mnote:mindmap-shell:panel";
|
||||
@@ -8245,6 +8246,64 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
register_runtime_listener(mount_id, target, command_listener);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Block-delta listener(Phase B:AI 写入增量通道) ──
|
||||
let delta_editor = editor;
|
||||
let delta_set_document_json = set_document_json;
|
||||
let delta_json_output = json_output;
|
||||
let delta_set_json_output = set_json_output;
|
||||
let delta_set_dirty_count = set_dirty_count;
|
||||
let delta_set_html_output = set_html_output;
|
||||
let delta_listener =
|
||||
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
|
||||
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
|
||||
return;
|
||||
};
|
||||
let detail = custom_event.detail();
|
||||
let Ok(delta): Result<Value, _> =
|
||||
serde_wasm_bindgen::from_value(detail)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(operations) = delta.get("operations").and_then(Value::as_array) else {
|
||||
return;
|
||||
};
|
||||
if operations.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Editor instance maybe unavailable while loading
|
||||
let Some(instance) = delta_editor.instance_untracked() else {
|
||||
return;
|
||||
};
|
||||
// Read current content
|
||||
let Ok(mut content) = instance.get_json() else {
|
||||
return;
|
||||
};
|
||||
// Apply delta operations to the Tiptap JSON tree
|
||||
let changed = apply_block_delta_to_json(&mut content, operations);
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
// Write back
|
||||
if instance.set_content(TiptapContent::json(content.clone())).is_ok() {
|
||||
// Update reactive state
|
||||
let html = instance.get_html().unwrap_or_default();
|
||||
let json_text = serde_json::to_string(&content).unwrap_or_default();
|
||||
delta_set_dirty_count.update(|c| *c += 1);
|
||||
delta_set_html_output.set(html);
|
||||
delta_set_json_output.set(json_text);
|
||||
delta_set_document_json.set(content);
|
||||
}
|
||||
}));
|
||||
|
||||
if let Some(document) = window().and_then(|win| win.document()) {
|
||||
let delta_ref = delta_listener.as_ref().unchecked_ref();
|
||||
let _ = document.add_event_listener_with_callback(BLOCK_DELTA_EVENT, delta_ref);
|
||||
if let Some((mount_id, _, _)) = runtime_mount_context() {
|
||||
let target: EventTarget = document.into();
|
||||
register_runtime_listener(mount_id, target, delta_listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11099,3 +11158,149 @@ pub fn standalone_main() {
|
||||
view! { <App mount_options=MountOptions::default()/> }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phase B:Block Delta apply ──────────────────────────────
|
||||
|
||||
/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。
|
||||
/// 返回 true 表示树发生了变更。
|
||||
fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool {
|
||||
let Some(content_arr) = content.get_mut("content").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
let mut changed = false;
|
||||
|
||||
for op_value in operations {
|
||||
let Some(op) = op_value.get("op").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
match op {
|
||||
"replace" => {
|
||||
let block_id = op_value.get("block_id").and_then(Value::as_str);
|
||||
let text = op_value.get("text").and_then(Value::as_str).unwrap_or("");
|
||||
let block_type = op_value.get("block_type").and_then(Value::as_str);
|
||||
if let Some(bid) = block_id {
|
||||
if apply_replace_block(content_arr, bid, text, block_type) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"insert_after" => {
|
||||
let anchor = op_value.get("anchor_block_id").and_then(Value::as_str);
|
||||
let block_id = op_value.get("block_id").and_then(Value::as_str);
|
||||
let text = op_value.get("text").and_then(Value::as_str).unwrap_or("");
|
||||
if let (Some(aid), Some(bid)) = (anchor, block_id) {
|
||||
if apply_insert_block_after(content_arr, aid, bid, text) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
let block_id = op_value.get("block_id").and_then(Value::as_str);
|
||||
if let Some(bid) = block_id {
|
||||
if apply_delete_block(content_arr, bid) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"move_after" => {
|
||||
let block_id = op_value.get("block_id").and_then(Value::as_str);
|
||||
let anchor = op_value.get("anchor_block_id").and_then(Value::as_str);
|
||||
if let (Some(bid), Some(aid)) = (block_id, anchor) {
|
||||
if apply_move_block_after(content_arr, bid, aid) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn find_block_index(blocks: &[Value], block_id: &str) -> Option<usize> {
|
||||
blocks.iter().position(|b| {
|
||||
b.get("attrs")
|
||||
.and_then(|a| a.get("block_id"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(block_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_replace_block(
|
||||
blocks: &mut Vec<Value>,
|
||||
block_id: &str,
|
||||
text: &str,
|
||||
block_type: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(idx) = find_block_index(blocks, block_id) else { return false; };
|
||||
let block = &mut blocks[idx];
|
||||
|
||||
// 更新 block type
|
||||
if let Some(bt) = block_type {
|
||||
if let Some(b) = block.as_object_mut() {
|
||||
b.insert("type".into(), json!(bt));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 text content
|
||||
let new_content: Value = if text.is_empty() {
|
||||
json!([])
|
||||
} else {
|
||||
json!([{ "type": "text", "text": text }])
|
||||
};
|
||||
|
||||
if let Some(b) = block.as_object_mut() {
|
||||
b.insert("content".into(), new_content);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_insert_block_after(
|
||||
blocks: &mut Vec<Value>,
|
||||
anchor_block_id: &str,
|
||||
new_block_id: &str,
|
||||
text: &str,
|
||||
) -> bool {
|
||||
let Some(idx) = find_block_index(blocks, anchor_block_id) else { return false; };
|
||||
let new_block = json!({
|
||||
"type": "paragraph",
|
||||
"attrs": { "block_id": new_block_id },
|
||||
"content": if text.is_empty() {
|
||||
json!([])
|
||||
} else {
|
||||
json!([{ "type": "text", "text": text }])
|
||||
}
|
||||
});
|
||||
blocks.insert(idx + 1, new_block);
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_delete_block(blocks: &mut Vec<Value>, block_id: &str) -> bool {
|
||||
let Some(idx) = find_block_index(blocks, block_id) else { return false; };
|
||||
blocks.remove(idx);
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_move_block_after(
|
||||
blocks: &mut Vec<Value>,
|
||||
block_id: &str,
|
||||
anchor_block_id: &str,
|
||||
) -> bool {
|
||||
let Some(block_idx) = find_block_index(blocks, block_id) else { return false; };
|
||||
let Some(anchor_idx) = find_block_index(blocks, anchor_block_id) else { return false; };
|
||||
|
||||
// Can't move to itself or anchor after block
|
||||
if block_idx == anchor_idx || block_idx == anchor_idx + 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let block = blocks.remove(block_idx);
|
||||
// After removal, anchor may have shifted if block was before anchor
|
||||
let adjusted_anchor = if block_idx < anchor_idx {
|
||||
anchor_idx - 1
|
||||
} else {
|
||||
anchor_idx
|
||||
};
|
||||
blocks.insert(adjusted_anchor + 1, block);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}}
|
||||
{"rustc_fingerprint":9228011546279038255,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
Reference in New Issue
Block a user