Files
mnote/rust/crates/mnote-web/src/editor_actor.rs
T
lix-2026 a2cb1338c8 chore: 保存当前架构收口与 bug 修复快照
归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
2026-05-18 17:01:35 +08:00

436 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 {
/// 连接广播 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(|| {
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(),
EditorBlockType::Resource => "resource".into(),
}
}