Files
mnote/rust/crates/mnote-web/src/editor_actor.rs
T

435 lines
14 KiB
Rust
Raw Normal View History

//! 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")]
2026-05-17 20:11:39 +08:00
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 {
/// 连接广播 channelPhase 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 后构建 BlockDeltaPhase 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(|| {
2026-05-17 20:11:39 +08:00
WebError::bad_request_code(
"mnote_editor_document_not_loaded",
format!("文档 {document_id} 尚未加载"),
)
})?;
let operations = match command {
EditorCommand::ReplaceBlock(cmd) => {
2026-05-17 20:11:39 +08:00
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);
2026-05-17 20:11:39 +08:00
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(),
}
}