430 lines
14 KiB
Rust
430 lines
14 KiB
Rust
//! 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(),
|
|||
|
|
}
|
|||
|
|
}
|