//! 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, 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, pub workspace_id: Option, pub new_revision: u64, pub conflict_detection_key: String, pub changed_blocks: Vec, pub warnings: Vec, 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, #[serde(skip_serializing_if = "Option::is_none")] pub after: Option, } /// 编辑器增量 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, } /// 一条增量操作,编辑器可通过 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, }, #[serde(rename = "insert_after")] InsertBlockAfter { anchor_block_id: String, block_id: String, text: String, #[serde(skip_serializing_if = "Option::is_none")] block_type: Option, }, #[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>>, block_delta_tx: Arc>>>, } impl EditorRuntimeActor { /// 连接广播 channel(Phase C:事件 stream delta) pub fn set_block_delta_tx(&self, tx: tokio::sync::broadcast::Sender) { 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 { 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 { 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 { 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 { 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 { 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 { 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 { let text = block .content_nodes .iter() .filter_map(|node| match &node.payload { ContentNodePayload::Text { text, .. } => Some(text.as_str()), _ => None, }) .collect::>() .join(""); if text.is_empty() { None } else { Some(text) } } fn block_text_from_content_nodes(nodes: &Option>) -> Option { nodes.as_ref().map(|nodes| { nodes .iter() .filter_map(|node| match &node.payload { ContentNodePayload::Text { text, .. } => Some(text.as_str()), _ => None, }) .collect::>() .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(), EditorBlockType::Resource => "resource".into(), } }