对齐 Wolai 侧栏体验并收拢设计入库
This commit is contained in:
@@ -2,7 +2,9 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::web_shell::{
|
||||
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
|
||||
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
|
||||
render_document_title_controller_script, render_editor_island_adapter_script,
|
||||
};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::workspace_shell::render_workspace_shell_sidebar_html;
|
||||
@@ -140,23 +142,79 @@ pub async fn root_entry(
|
||||
.active_page_title
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_id={workspace_id.clone()} workspace_sidebar_html={workspace_sidebar_html} active_page_id={active_page_id} active_page_title={active_page_title} />
|
||||
});
|
||||
let render_workspace_entry = || {
|
||||
crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::home::HomePage
|
||||
sidebar_tree_html={sidebar_tree_html.clone()}
|
||||
workspace_name={workspace_name.clone()}
|
||||
workspace_id={workspace_id.clone()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
active_page_id={active_page_id.clone()}
|
||||
active_page_title={active_page_title.clone()}
|
||||
/>
|
||||
})
|
||||
};
|
||||
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
|
||||
("MNOTE".to_string(), render_workspace_entry(), String::new())
|
||||
} else {
|
||||
match build_page_aggregate_snapshot(
|
||||
&state,
|
||||
&context,
|
||||
&active_page_id,
|
||||
Some(workspace_id.as_str()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(aggregate) => {
|
||||
let title = aggregate.head.title.as_str();
|
||||
let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree)
|
||||
.unwrap_or_else(|_| "null".to_string());
|
||||
let snapshot_json =
|
||||
serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
||||
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::document::DocumentPage
|
||||
title={title.to_string()}
|
||||
document_id={active_page_id.clone()}
|
||||
workspace_id={workspace_id.clone()}
|
||||
sidebar_tree_html={sidebar_tree_html.clone()}
|
||||
workspace_name={workspace_name.clone()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
page_subtree_json={page_subtree_json}
|
||||
/>
|
||||
});
|
||||
let body_extra = format!(
|
||||
r#"<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
|
||||
<script id="__MNOTE_EDITOR_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}
|
||||
{}"#,
|
||||
escape_script_json(&snapshot_json),
|
||||
escape_script_json(&bootstrap_json),
|
||||
render_document_title_controller_script(),
|
||||
render_editor_island_adapter_script(),
|
||||
);
|
||||
(title.to_string(), content, body_extra)
|
||||
}
|
||||
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
|
||||
}
|
||||
};
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MNOTE</title>
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(&html_title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
content
|
||||
content,
|
||||
body_extra
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
|
||||
@@ -36,7 +36,7 @@ use bridge_runtime::RuntimeCommandEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -209,7 +209,7 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
|
||||
}
|
||||
|
||||
pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
|
||||
projection
|
||||
let mut rows: Vec<PageTreeRenderRow> = projection
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
@@ -232,6 +232,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
|
||||
node_id: node_id.to_string(),
|
||||
parent_node_id: item
|
||||
.get("parentNodeId")
|
||||
.or_else(|| item.get("parentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -261,7 +262,30 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let parent_by_id = rows
|
||||
.iter()
|
||||
.map(|row| (row.node_id.clone(), row.parent_node_id.clone()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for row in &mut rows {
|
||||
if row.depth == 0 && row.parent_node_id.is_some() {
|
||||
let mut depth = 0_u32;
|
||||
let mut cursor = row.parent_node_id.as_deref();
|
||||
while let Some(parent_id) = cursor {
|
||||
depth += 1;
|
||||
cursor = parent_by_id
|
||||
.get(parent_id)
|
||||
.and_then(|parent| parent.as_deref());
|
||||
if depth > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
row.depth = depth;
|
||||
}
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
pub(crate) fn collect_filetree_render_rows(
|
||||
|
||||
@@ -126,7 +126,10 @@ pub async fn document_page_shell(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestContext) -> String {
|
||||
pub(crate) fn build_editor_bootstrap_json(
|
||||
aggregate: &PageAggregate,
|
||||
context: &RequestContext,
|
||||
) -> String {
|
||||
serde_json::to_string(&json!({
|
||||
"schema": "mnote.editor_bootstrap.v1",
|
||||
"documentId": aggregate.identity.document_id,
|
||||
@@ -142,7 +145,7 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn render_document_title_controller_script() -> &'static str {
|
||||
pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
r#"<script>
|
||||
(() => {
|
||||
const CONTRACT = 'mnote.document_title_controller.v1';
|
||||
@@ -259,7 +262,7 @@ fn render_document_title_controller_script() -> &'static str {
|
||||
</script>"#
|
||||
}
|
||||
|
||||
fn render_editor_island_adapter_script() -> &'static str {
|
||||
pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
r#"<script type="module">
|
||||
(() => {
|
||||
const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]';
|
||||
@@ -586,7 +589,7 @@ pub async fn page_aggregate(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn build_page_aggregate_snapshot(
|
||||
pub(crate) async fn build_page_aggregate_snapshot(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
@@ -661,7 +664,7 @@ fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
pub(crate) fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
@@ -669,7 +672,7 @@ fn escape_html(value: &str) -> String {
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
fn escape_script_json(value: &str) -> String {
|
||||
pub(crate) fn escape_script_json(value: &str) -> String {
|
||||
value.replace("</script", "<\\/script")
|
||||
}
|
||||
|
||||
|
||||
@@ -252,22 +252,28 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function renderPageRows(parentId, grouped, activeId) {
|
||||
function pageTreeChevronSvg() {
|
||||
return '<svg class="tree-toggle-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false"><path d="M7.84 14.955c.206 0 .37-.07.505-.21l4.277-4.179a.79.79 0 0 0 .264-.574.78.78 0 0 0-.258-.574L8.35 5.24a.7.7 0 0 0-.51-.21.721.721 0 0 0-.498 1.247l3.814 3.721-3.814 3.709a.721.721 0 0 0 .498 1.248"></path></svg>';
|
||||
}
|
||||
|
||||
function renderPageRows(parentId, grouped, activeId, inheritedDepth) {
|
||||
var computedDepth = Number(inheritedDepth || 0);
|
||||
return (grouped.get(parentId) || []).map(function(item) {
|
||||
var nodeId = nodeIdOf(item);
|
||||
var title = titleOf(item);
|
||||
var depth = Number(item.depth || 0);
|
||||
var depth = computedDepth;
|
||||
var parent = parentIdOf(item);
|
||||
var children = grouped.get(nodeId) || [];
|
||||
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
|
||||
var expanded = expandable && item.expandedByDefault !== false;
|
||||
var toggle = expandable
|
||||
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '▸') + '</button>'
|
||||
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '" aria-expanded="' + String(expanded) + '">' + pageTreeChevronSvg() + '</button>'
|
||||
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
||||
var childHtml = expandable && expanded
|
||||
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId) + '</ul>'
|
||||
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '</ul>'
|
||||
: '';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '" data-focused="false" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="' + escapeHtml(nodeId) + '" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||
var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"';
|
||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
@@ -278,7 +284,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return String(item.rowKind || 'document') === 'document';
|
||||
});
|
||||
var activeId = currentDocumentId();
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId) : '<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>') + '</ul>';
|
||||
tree.innerHTML = '<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '<li class="tree-empty" data-rust-rendered-row="page-empty">暂无页面</li>') + '</ul>';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -373,7 +379,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
children.classList.toggle('tree-children--collapsed');
|
||||
var collapsed = children.classList.contains('tree-children--collapsed');
|
||||
row.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
if (button) button.textContent = collapsed ? '▸' : '▾';
|
||||
if (button && button.getAttribute('data-testid') === 'tree-node-toggle') {
|
||||
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
} else if (button) {
|
||||
button.textContent = collapsed ? '▸' : '▾';
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchSidebarEvent(name, detail) {
|
||||
@@ -588,20 +598,20 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'move', icon: 'drive_file_move', label: '移动到...' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' }
|
||||
] : [
|
||||
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt + O' },
|
||||
{ action: 'share', icon: 'share', label: '共享...' },
|
||||
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' },
|
||||
{ separator: true },
|
||||
{ action: 'move', icon: 'drive_file_move', label: '移动到...' },
|
||||
{ action: 'embed', icon: 'account_tree', label: '嵌入到...' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-link', icon: 'link', label: '复制访问链接' },
|
||||
{ action: 'copy-link-title', icon: 'link', label: '复制访问链接(带标题)' },
|
||||
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面 ID' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
|
||||
{ separator: true },
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名' },
|
||||
{ action: 'create-child', icon: 'add', label: '新建子页面' },
|
||||
{ action: 'convert-child', icon: 'subdirectory_arrow_right', label: '转为上一个子页面' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除到垃圾桶', danger: true }
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
|
||||
];
|
||||
items.forEach(function(item) { appendTreeContextMenuButton(menu, item, detail, trigger); });
|
||||
document.body.appendChild(menu);
|
||||
@@ -638,10 +648,155 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}, x, y, trigger || row);
|
||||
}
|
||||
|
||||
function ensureSearchModal() {
|
||||
var existing = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
if (existing instanceof HTMLElement) return existing;
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'wolai-search-overlay';
|
||||
overlay.setAttribute('data-testid', 'wolai-search-modal');
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.hidden = true;
|
||||
|
||||
overlay.innerHTML = '' +
|
||||
'<div class="wolai-search-dialog">' +
|
||||
'<div class="wolai-search-input-row">' +
|
||||
'<span class="material-symbols-outlined wolai-search-input-icon" data-icon="search" aria-hidden="true"></span>' +
|
||||
'<input data-testid="wolai-search-input" class="wolai-search-input" type="text" autocomplete="off" placeholder="在当前工作区中搜索" />' +
|
||||
'<button type="button" class="wolai-search-close" data-testid="wolai-search-close" aria-label="关闭搜索">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-search-options" aria-label="搜索选项">' +
|
||||
'<div class="wolai-search-options-left">' +
|
||||
'<span class="wolai-search-switch-control"><span>仅匹配标题</span><button type="button" class="wolai-search-switch is-on" data-search-switch="title" role="switch" aria-checked="true" aria-label="仅匹配标题"></button></span>' +
|
||||
'<span class="wolai-search-switch-control"><span>精确匹配</span><button type="button" class="wolai-search-switch" data-search-switch="exact" role="switch" aria-checked="false" aria-label="精确匹配"></button></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>按编辑时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="updated" aria-label="按编辑时间范围">所有</button></span>' +
|
||||
'<span class="wolai-search-sort-control"><span>按创建时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="created" aria-label="按创建时间范围">所有</button></span>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-search-options-right">' +
|
||||
'<span class="wolai-search-switch-control"><span>页面内搜索</span><button type="button" class="wolai-search-switch is-on" data-search-switch="page" role="switch" aria-checked="true" aria-label="页面内搜索"></button></span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wolai-search-result-meta" data-testid="wolai-search-result-meta"></div>' +
|
||||
'<div class="wolai-search-results" data-testid="wolai-search-results"></div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
|
||||
var closeButton = overlay.querySelector('[data-testid="wolai-search-close"]');
|
||||
if (input) input.addEventListener('input', renderSearchResults);
|
||||
overlay.querySelectorAll('[data-search-switch]').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
var isOn = button.getAttribute('aria-checked') !== 'true';
|
||||
button.setAttribute('aria-checked', isOn ? 'true' : 'false');
|
||||
button.classList.toggle('is-on', isOn);
|
||||
});
|
||||
});
|
||||
if (closeButton) closeButton.addEventListener('click', closeSearchModal);
|
||||
overlay.addEventListener('click', function(event) {
|
||||
if (event.target === overlay) closeSearchModal();
|
||||
});
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function searchText(value) {
|
||||
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function collectSearchCandidates() {
|
||||
var seen = Object.create(null);
|
||||
var candidates = [];
|
||||
function add(title, source) {
|
||||
title = searchText(title);
|
||||
if (!title || seen[title]) return;
|
||||
seen[title] = true;
|
||||
candidates.push({ title: title, source: source || '当前工作区' });
|
||||
}
|
||||
document.querySelectorAll('.tree-link-title, .wolai-row-title, [data-page-title-current="true"], .document-title-input, .document-read-view h1, .document-shell h1').forEach(function(node) {
|
||||
add(node.textContent, '页面');
|
||||
});
|
||||
document.querySelectorAll('.mnote-content h1, .mnote-content h2, .mnote-content h3, .mnote-content p, .mnote-content a').forEach(function(node) {
|
||||
add(node.textContent, '当前页面');
|
||||
});
|
||||
return candidates.slice(0, 120);
|
||||
}
|
||||
|
||||
function highlightSearchTitle(title, query) {
|
||||
var cleanTitle = searchText(title);
|
||||
var cleanQuery = searchText(query);
|
||||
if (!cleanQuery) return escapeHtml(cleanTitle);
|
||||
var index = cleanTitle.toLowerCase().indexOf(cleanQuery.toLowerCase());
|
||||
if (index < 0) return escapeHtml(cleanTitle);
|
||||
return escapeHtml(cleanTitle.slice(0, index)) +
|
||||
'<mark>' + escapeHtml(cleanTitle.slice(index, index + cleanQuery.length)) + '</mark>' +
|
||||
escapeHtml(cleanTitle.slice(index + cleanQuery.length));
|
||||
}
|
||||
|
||||
function renderSearchResults() {
|
||||
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
if (!(overlay instanceof HTMLElement)) return;
|
||||
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
|
||||
var meta = overlay.querySelector('[data-testid="wolai-search-result-meta"]');
|
||||
var results = overlay.querySelector('[data-testid="wolai-search-results"]');
|
||||
if (!input || !meta || !results) return;
|
||||
var query = searchText(input.value);
|
||||
var candidates = collectSearchCandidates();
|
||||
var filtered = query
|
||||
? candidates.filter(function(item) { return item.title.toLowerCase().indexOf(query.toLowerCase()) >= 0; })
|
||||
: candidates.slice(0, 8);
|
||||
filtered = filtered.slice(0, 100);
|
||||
meta.innerHTML = '<span>共 ' + filtered.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
|
||||
if (!filtered.length) {
|
||||
results.innerHTML = '<div class="wolai-search-empty">暂无匹配结果</div>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = filtered.map(function(item) {
|
||||
return '<button type="button" class="wolai-search-result-row">' +
|
||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="link" aria-hidden="true"></span>' +
|
||||
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(item.title, query) + '</span>' +
|
||||
'<span class="wolai-search-result-snippet">' + escapeHtml(item.source) + ' · 当前工作区</span></span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function isSearchModalOpen() {
|
||||
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
return overlay instanceof HTMLElement && !overlay.hidden;
|
||||
}
|
||||
|
||||
function openSearchModal() {
|
||||
var overlay = ensureSearchModal();
|
||||
overlay.hidden = false;
|
||||
document.documentElement.setAttribute('data-mnote-search-modal-open', 'true');
|
||||
renderSearchResults();
|
||||
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
|
||||
if (input) {
|
||||
setTimeout(function() { input.focus(); input.select(); }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function closeSearchModal() {
|
||||
var overlay = document.querySelector('[data-testid="wolai-search-modal"]');
|
||||
if (overlay instanceof HTMLElement) overlay.hidden = true;
|
||||
document.documentElement.removeAttribute('data-mnote-search-modal-open');
|
||||
}
|
||||
|
||||
function toggleSearchModal() {
|
||||
if (isSearchModalOpen()) closeSearchModal();
|
||||
else openSearchModal();
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
|
||||
if (activeTreeContextMenu) closeTreeContextMenu();
|
||||
|
||||
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
|
||||
if (searchTrigger) {
|
||||
e.preventDefault();
|
||||
openSearchModal();
|
||||
return;
|
||||
}
|
||||
|
||||
var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]');
|
||||
if (tabTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -753,7 +908,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') closeTreeContextMenu();
|
||||
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
|
||||
event.preventDefault();
|
||||
toggleSearchModal();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
closeSearchModal();
|
||||
closeTreeContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
function readPageDragNodeId(event) {
|
||||
@@ -1146,7 +1309,7 @@ pub fn PageLayout(
|
||||
<span class="wolai-sidebar-chevron" aria-hidden="true">"⌄"</span>
|
||||
</div>
|
||||
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
|
||||
<a href="/search" class:active={current_nav == "search"} title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined nav-icon" data-icon="search" aria-hidden="true"></span></a>
|
||||
<a href="/graph" title="关系图" aria-label="关系图"><span class="material-symbols-outlined nav-icon" data-icon="account_tree" aria-hidden="true"></span></a>
|
||||
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
|
||||
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
|
||||
@@ -1173,7 +1336,7 @@ pub fn PageLayout(
|
||||
<button type="button" class="wolai-icon-button" title="收藏" aria-label="收藏"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="历史" aria-label="历史"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button wolai-ai-inline" title="AI" aria-label="AI"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="搜索" aria-label="搜索"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="搜索" aria-label="搜索" data-mnote-action="open-search-modal"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span></button>
|
||||
<button type="button" class="wolai-icon-button" title="更多" aria-label="更多"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -910,6 +910,39 @@ a:hover {
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
|
||||
|
||||
.wolai-search-overlay{position:fixed;inset:0;z-index:1200;display:flex;align-items:flex-start;justify-content:center;padding:92px 24px 24px;background:rgba(0,0,0,.32)}
|
||||
.wolai-search-overlay[hidden]{display:none!important}
|
||||
.wolai-search-dialog{width:min(680px,100%);max-height:min(720px,calc(100vh - 128px));overflow:hidden;display:flex;flex-direction:column;background:#FFF;border:1px solid rgba(27,28,28,.10);border-radius:6px;box-shadow:0 18px 48px rgba(15,23,42,.22),0 2px 8px rgba(15,23,42,.08);color:#37352F}
|
||||
.wolai-search-input-row{display:flex;align-items:center;min-height:58px;padding:0 12px 0 18px;border-bottom:1px solid rgba(27,28,28,.08)}
|
||||
.wolai-search-input-icon{width:22px;height:22px;color:#8B8780;margin-right:10px}
|
||||
.wolai-search-input{flex:1 1 auto;min-width:0;height:56px;border:0;outline:none;background:transparent;color:#24211D;font:400 18px/1.4 var(--wolai-font-sans);letter-spacing:0}
|
||||
.wolai-search-input::placeholder{color:#A29E97}
|
||||
.wolai-search-close{width:28px;height:28px;border:0;border-radius:4px;background:transparent;color:#8B8780;cursor:pointer;font-size:20px;line-height:1}
|
||||
.wolai-search-options{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px 8px;border-bottom:1px solid rgba(27,28,28,.06);color:#A7A39D;font-size:12px;line-height:1}
|
||||
.wolai-search-options-left,.wolai-search-options-right,.wolai-search-switch-control,.wolai-search-sort-control{display:inline-flex;align-items:center}
|
||||
.wolai-search-options-left{min-width:0;flex-wrap:wrap;gap:8px 12px}
|
||||
.wolai-search-options-right{margin-left:auto;flex:0 0 auto}
|
||||
.wolai-search-switch-control,.wolai-search-sort-control{gap:5px;white-space:nowrap}
|
||||
.wolai-search-switch,.wolai-search-sort-value{border:0;background:transparent;cursor:pointer;font:inherit;letter-spacing:0}
|
||||
.wolai-search-switch{position:relative;width:28px;height:16px;border-radius:999px;background:#D6D4D0;box-shadow:inset 0 0 0 1px rgba(27,28,28,.04)}
|
||||
.wolai-search-switch::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:999px;background:#FFF;box-shadow:0 1px 2px rgba(15,23,42,.22)}
|
||||
.wolai-search-switch.is-on{background:#C9C7C3}
|
||||
.wolai-search-switch.is-on::after{transform:translateX(12px)}
|
||||
.wolai-search-sort-value{display:inline-flex;align-items:center;gap:3px;color:#6D6A65}
|
||||
.wolai-search-sort-value::after{content:"⌄";color:#B8B4AD;font-size:13px;line-height:1}
|
||||
.wolai-search-result-meta{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 16px;color:#8B8780;font-size:12px;line-height:1.3;border-bottom:1px solid rgba(27,28,28,.06)}
|
||||
.wolai-search-results{overflow:auto;padding:6px}
|
||||
.wolai-search-result-row{width:100%;min-height:54px;display:flex;align-items:flex-start;gap:10px;padding:9px 10px;border:0;border-radius:4px;background:transparent;color:#37352F;cursor:pointer;text-align:left;font:inherit}
|
||||
.wolai-search-result-row:hover{background:rgba(55,53,47,.08)}
|
||||
.wolai-search-result-icon{width:18px;height:18px;margin-top:2px;color:#8B8780}
|
||||
.wolai-search-result-main{min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
.wolai-search-result-title{color:#2F2D29;font-size:14px;line-height:1.35;word-break:break-word}
|
||||
.wolai-search-result-title mark{padding:0 1px;border-radius:2px;background:#FFE9E6;color:#D83A32}
|
||||
.wolai-search-result-snippet,.wolai-search-empty{color:#8B8780;font-size:12px;line-height:1.35}
|
||||
.wolai-search-empty{padding:28px 12px 32px;text-align:center}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mnote-sidebar {
|
||||
width: 200px;
|
||||
@@ -987,7 +1020,7 @@ a:hover {
|
||||
/* ===== Stitch 260429 UI parity overrides ===== */
|
||||
:root {
|
||||
--atelier-surface: #FAF9F9;
|
||||
--atelier-sidebar: #F4F3F3;
|
||||
--atelier-sidebar: #F5F5F5;
|
||||
--atelier-sidebar-hover: #ECEBE9;
|
||||
--atelier-sidebar-active: #E3E2E2;
|
||||
--atelier-document: #FFFFFF;
|
||||
@@ -1031,26 +1064,26 @@ body {
|
||||
|
||||
.mnote-sidebar,
|
||||
.wolai-sidebar {
|
||||
width: 240px;
|
||||
width: 248px;
|
||||
background: var(--atelier-sidebar);
|
||||
border-right: 0;
|
||||
box-shadow: inset -1px 0 0 rgba(27, 28, 28, 0.06);
|
||||
box-shadow: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.mnote-sidebar-header,
|
||||
.wolai-sidebar-header {
|
||||
height: 68px;
|
||||
padding: 16px 12px 8px;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: #D6534D;
|
||||
font-size: 17px;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
@@ -1058,9 +1091,9 @@ body {
|
||||
.sidebar-workspace-name {
|
||||
max-width: 160px;
|
||||
color: var(--atelier-text);
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.wolai-sidebar-chevron {
|
||||
@@ -1070,8 +1103,8 @@ body {
|
||||
|
||||
.wolai-quick-actions {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 8px 16px 26px;
|
||||
gap: 10.4px;
|
||||
padding: 5px 8px 11px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a,
|
||||
@@ -1080,9 +1113,9 @@ body {
|
||||
}
|
||||
|
||||
.wolai-quick-actions a {
|
||||
height: 28px;
|
||||
height: 30px;
|
||||
color: #3F3D39;
|
||||
font-size: 19px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wolai-quick-actions a:hover {
|
||||
@@ -1099,13 +1132,14 @@ body {
|
||||
|
||||
.wolai-section-title,
|
||||
.wolai-sidebar-tabs {
|
||||
height: 30px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
color: var(--atelier-text-muted);
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1237,13 +1271,14 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row {
|
||||
height: 29px;
|
||||
gap: 5px;
|
||||
margin: 1px 4px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
gap: 0;
|
||||
margin: 0 5px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
color: #2D2E2E;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row:hover {
|
||||
@@ -1252,8 +1287,8 @@ body {
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"],
|
||||
.sidebar-tree .tree-row[data-selected="true"] {
|
||||
background: var(--atelier-sidebar-active);
|
||||
color: var(--atelier-text);
|
||||
background: rgba(255, 71, 71, 0.1);
|
||||
color: #E0525B;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-feedback="true"],
|
||||
@@ -1265,16 +1300,44 @@ body {
|
||||
|
||||
.sidebar-tree .tree-toggle,
|
||||
.sidebar-tree .tree-spacer {
|
||||
width: 16px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 24px;
|
||||
color: #B8B5AF;
|
||||
font-size: 13px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle {
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle:hover {
|
||||
background: rgba(27, 28, 28, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
fill: #878787;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle[aria-expanded="true"] .tree-toggle-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"] .tree-toggle-icon {
|
||||
fill: #CF5659;
|
||||
}
|
||||
|
||||
.sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -1295,7 +1358,7 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="page"]::before {
|
||||
content: "⌂";
|
||||
content: "";
|
||||
}
|
||||
|
||||
.sidebar-tree[data-tree-shell-mode="filetree"] .tree-kind-badge[data-kind="page"]::before,
|
||||
@@ -1333,7 +1396,7 @@ body {
|
||||
.sidebar-tree .tree-link {
|
||||
padding: 0 2px;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-link-title {
|
||||
@@ -1341,17 +1404,18 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"] .tree-link-title {
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-actions {
|
||||
gap: 1px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-action {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #9A968F;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #B3B3B3;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -1363,31 +1427,33 @@ body {
|
||||
.mnote-tree-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 236px;
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
max-width: min(320px, calc(100vw - 16px));
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 16px 32px rgba(27, 28, 28, 0.12);
|
||||
border-radius: 6px;
|
||||
background: #FFFFFF;
|
||||
box-shadow: 0 8px 20px rgba(27, 28, 28, 0.12);
|
||||
backdrop-filter: blur(24px);
|
||||
color: var(--atelier-text);
|
||||
}
|
||||
|
||||
.mnote-tree-context-menu__item {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 9px;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
line-height: 22px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1439,7 +1505,7 @@ body {
|
||||
|
||||
.mnote-tree-context-menu__separator {
|
||||
height: 1px;
|
||||
margin: 5px 4px;
|
||||
margin: 6px 0;
|
||||
background: rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
@@ -1468,7 +1534,7 @@ body {
|
||||
}
|
||||
|
||||
.wolai-sidebar-footer {
|
||||
border-top: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-top: 0;
|
||||
background: var(--atelier-sidebar);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRo
|
||||
.collect()
|
||||
}
|
||||
|
||||
const PAGE_TREE_CHEVRON_SVG: &str = r#"<svg class="tree-toggle-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false"><path d="M7.84 14.955c.206 0 .37-.07.505-.21l4.277-4.179a.79.79 0 0 0 .264-.574.78.78 0 0 0-.258-.574L8.35 5.24a.7.7 0 0 0-.51-.21.721.721 0 0 0-.498 1.247l3.814 3.721-3.814 3.709a.721.721 0 0 0 .498 1.248"></path></svg>"#;
|
||||
|
||||
fn escape_html(input: &str) -> String {
|
||||
input
|
||||
.replace('&', "&")
|
||||
@@ -54,6 +56,26 @@ fn escape_html(input: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn render_depth_for_node(rows: &[PageTreeRenderRow], node_id: &str, fallback_depth: u32) -> u32 {
|
||||
if fallback_depth > 0 {
|
||||
return fallback_depth;
|
||||
}
|
||||
let parent_by_id = rows
|
||||
.iter()
|
||||
.map(|row| (row.node_id.as_str(), row.parent_node_id.as_deref()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut depth = 0_u32;
|
||||
let mut cursor = parent_by_id.get(node_id).and_then(|parent| *parent);
|
||||
while let Some(parent_id) = cursor {
|
||||
depth += 1;
|
||||
cursor = parent_by_id.get(parent_id).and_then(|parent| *parent);
|
||||
if depth > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
fn render_page_row(
|
||||
html: &mut String,
|
||||
row: &PageTreeRenderRow,
|
||||
@@ -80,11 +102,12 @@ fn render_page_row(
|
||||
.unwrap_or(false);
|
||||
let toggle_html = if row.expandable {
|
||||
format!(
|
||||
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}">{marker}</button>"#,
|
||||
r#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}" aria-expanded="{expanded_state}">{marker}</button>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
label = if expanded { "折叠" } else { "展开" },
|
||||
title = escape_html(&row.title),
|
||||
marker = if expanded { "▾" } else { "▸" },
|
||||
expanded_state = if expanded { "true" } else { "false" },
|
||||
marker = PAGE_TREE_CHEVRON_SVG,
|
||||
)
|
||||
} else {
|
||||
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
|
||||
@@ -96,15 +119,18 @@ fn render_page_row(
|
||||
.and_then(|source| source.parent_node_id.as_deref())
|
||||
.map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id)))
|
||||
.unwrap_or_default();
|
||||
let render_depth = render_depth_for_node(&input.rows, &row.node_id, row.depth);
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
aria_level = row.depth + 1,
|
||||
aria_level = render_depth + 1,
|
||||
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
|
||||
test_id = row.test_id,
|
||||
parent_attr = parent_attr,
|
||||
depth = row.depth,
|
||||
depth = render_depth,
|
||||
active = active,
|
||||
selected = active,
|
||||
current_attr = if active { r#" aria-current="page""# } else { "" },
|
||||
focused = focused,
|
||||
tab_index = if focused { "0" } else { "-1" },
|
||||
toggle_html = toggle_html,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Rust Design Index
|
||||
|
||||
更新时间:2026-04-13
|
||||
适用范围:`/mnt/Data1T/mnote/rust/design`
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前状态
|
||||
|
||||
当前目录只承载已经复制进 `/mnt/Data1T/mnote/rust/` 的核心稳定设计,用于支撑单仓收口后的 Rust 内核落位。
|
||||
|
||||
当前已复制:
|
||||
|
||||
- `core/01-domain-model-v0.md`
|
||||
- `core/02-command-query-tool-protocol-v0.md`
|
||||
- `core/03-storage-event-indexing-v0.md`
|
||||
- `core/04-onlyoffice-integration-boundary-v0.md`
|
||||
|
||||
这些文档对应当前已复制进 `/mnt/Data1T/mnote/rust/crates/` 的 P0 crate:
|
||||
|
||||
- `core-domain`
|
||||
- `core-protocol`
|
||||
- `event-log`
|
||||
- `storage-convex-bridge`
|
||||
- `index-fts`
|
||||
|
||||
---
|
||||
|
||||
## 2. 阅读顺序
|
||||
|
||||
后续在 `mnote` 主仓处理 Rust 内核时,建议按以下顺序阅读:
|
||||
|
||||
1. `core/01-domain-model-v0.md`
|
||||
2. `core/02-command-query-tool-protocol-v0.md`
|
||||
3. `core/03-storage-event-indexing-v0.md`
|
||||
4. `core/04-onlyoffice-integration-boundary-v0.md`
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前不在本目录的历史资料
|
||||
|
||||
以下资料仍保留在 `/mnt/Data1T/mnote-rust/design/`,当前作为参考,不属于本次第一阶段强制复制范围:
|
||||
|
||||
- `blueprint/`
|
||||
- `phases/`
|
||||
- `execution/`
|
||||
- `UI/`
|
||||
|
||||
如果后续需要继续把历史阶段文档并入主仓,应在复制前先做去历史化整理,避免把旧阶段叙事原样带入 `mnote` 主仓。
|
||||
|
||||
---
|
||||
|
||||
## 4. 约定
|
||||
|
||||
- `/mnt/Data1T/mnote/design/` 继续承担全局架构与跨系统路线说明。
|
||||
- `/mnt/Data1T/mnote/rust/design/` 只承载 Rust 内核专属设计。
|
||||
- 新增 Rust 设计文档时,优先补充这里的索引,再补充 `/mnt/Data1T/mnote/design/old/08-legacy-rust-kernel/process/rust-kernel-backport-plan.md` 的实施状态。
|
||||
@@ -0,0 +1,665 @@
|
||||
# 01. Domain Model v0
|
||||
|
||||
更新时间:2026-04-11
|
||||
适用范围:`/mnt/Data1T/mnote-rust`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本文件定义 `mnote-rust` 的**核心领域模型**。
|
||||
|
||||
它要解决的问题不是“前端怎么渲染”,而是:
|
||||
|
||||
- 系统里到底有哪些一等对象
|
||||
- 哪些对象是事实层真相,哪些只是派生视图
|
||||
- AI / CLI / Web / Desktop 应该围绕什么稳定对象工作
|
||||
- OnlyOffice、Mindmap、OCR、RAG 这类能力应挂在哪一层
|
||||
|
||||
本文件优先保证:
|
||||
|
||||
- 长期稳定
|
||||
- 脱离 UI 依赖
|
||||
- 适合 Rust 内核实现
|
||||
- 适合 AI 通过结构化协议读写
|
||||
|
||||
---
|
||||
|
||||
## 2. 建模原则
|
||||
|
||||
### 2.1 事实与视图分离
|
||||
|
||||
以下对象属于**事实层**:
|
||||
|
||||
- Workspace
|
||||
- Page
|
||||
- Block
|
||||
- Asset
|
||||
- Reference
|
||||
- Task
|
||||
- CommandLog
|
||||
- Event
|
||||
- AgentSession
|
||||
|
||||
以下对象默认属于**派生层 / 视图层**:
|
||||
|
||||
- 目录(TOC)
|
||||
- 反链聚合
|
||||
- 搜索结果列表
|
||||
- Mindmap 视图树
|
||||
- OnlyOffice 当前光标/当前页/当前选区
|
||||
- AI 面板当前消息列表 UI 状态
|
||||
|
||||
原则:
|
||||
|
||||
> 派生层可以重建;事实层必须稳定、可审计、可迁移。
|
||||
|
||||
### 2.2 人与 AI 共用同一套领域对象
|
||||
|
||||
不能做人类一套模型、AI 一套模型。
|
||||
|
||||
要求:
|
||||
|
||||
- 人类编辑页面,本质是修改 `Page / Block / Asset / Reference`
|
||||
- AI 编辑页面,本质也是修改同样的对象
|
||||
- CLI 批处理、同步任务、导入器也修改同样的对象
|
||||
|
||||
### 2.3 第三方编辑器不是事实源
|
||||
|
||||
Block editor、OnlyOffice、mindmap editor 都不是系统真相。
|
||||
|
||||
它们只能是:
|
||||
|
||||
- 某类对象的视图/交互适配器
|
||||
- 输入输出变换器
|
||||
- 外部文档能力宿主
|
||||
|
||||
### 2.4 结构化修改优先
|
||||
|
||||
优先通过:
|
||||
|
||||
- 显式对象
|
||||
- 显式字段
|
||||
- 显式命令
|
||||
- 显式 ops
|
||||
|
||||
避免通过:
|
||||
|
||||
- UI 状态猜测
|
||||
- 整文全文字符串替换
|
||||
- 第三方编辑器内部瞬时结构直接落库
|
||||
|
||||
---
|
||||
|
||||
## 3. 对象总览
|
||||
|
||||
```text
|
||||
Workspace
|
||||
├─ Page
|
||||
│ ├─ DocumentBody
|
||||
│ │ └─ Block*
|
||||
│ ├─ PageProperty*
|
||||
│ ├─ PageLink*
|
||||
│ └─ Snapshot*
|
||||
├─ Asset*
|
||||
├─ Task*
|
||||
├─ AgentSession*
|
||||
├─ CommandLog*
|
||||
└─ Event*
|
||||
|
||||
Reference
|
||||
├─ block -> block
|
||||
├─ block -> page
|
||||
├─ block -> asset-fragment
|
||||
├─ page -> asset
|
||||
└─ task -> page/block/asset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Workspace
|
||||
|
||||
`Workspace` 是系统的顶层容器。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `workspace_id`
|
||||
- `slug`
|
||||
- `title`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `owner_actor_id`
|
||||
- `default_locale`
|
||||
- `storage_policy`
|
||||
- `sync_policy`
|
||||
- `feature_flags`
|
||||
- `archived_at`
|
||||
|
||||
职责:
|
||||
|
||||
- 隔离页面、资产、任务、日志
|
||||
- 挂载存储策略与同步策略
|
||||
- 作为权限边界与导出边界
|
||||
|
||||
不负责:
|
||||
|
||||
- 具体页面内容
|
||||
- UI 布局
|
||||
- 当前用户会话态
|
||||
|
||||
---
|
||||
|
||||
## 5. Page
|
||||
|
||||
`Page` 是笔记系统的主内容容器。
|
||||
|
||||
### 5.1 Page 的定位
|
||||
|
||||
它不是“某个编辑器文件”,而是:
|
||||
|
||||
- 一条知识对象
|
||||
- 一个文档入口
|
||||
- 一个块树正文容器
|
||||
- 一个可被引用、索引、审计的实体
|
||||
|
||||
### 5.2 建议字段
|
||||
|
||||
- `page_id`
|
||||
- `workspace_id`
|
||||
- `parent_page_id`(允许页面树)
|
||||
- `title`
|
||||
- `slug`
|
||||
- `icon`
|
||||
- `cover_asset_id`
|
||||
- `body_root_block_id`
|
||||
- `page_type`(`note | doc | database_record | inbox | template | system`)
|
||||
- `status`(`active | archived | deleted`)
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `last_edited_at`
|
||||
- `last_edited_by`
|
||||
- `current_revision`
|
||||
|
||||
### 5.3 PageProperty
|
||||
|
||||
页面属性不应和前端表格视图绑死。
|
||||
|
||||
建议独立对象:
|
||||
|
||||
- `property_key`
|
||||
- `value_type`
|
||||
- `value`
|
||||
- `display_hint`
|
||||
- `source`
|
||||
|
||||
可用于:
|
||||
|
||||
- 标签
|
||||
- 时间
|
||||
- 状态
|
||||
- 优先级
|
||||
- 自定义结构化元数据
|
||||
|
||||
---
|
||||
|
||||
## 6. DocumentBody 与 Block
|
||||
|
||||
### 6.1 为什么以 Block 为正文主模型
|
||||
|
||||
因为从既有 `mnote` / `mnote-next` 的稳定人层语义看,真正稳定的是:
|
||||
|
||||
- 单块编辑
|
||||
- 在某块后插入块
|
||||
- 删除块
|
||||
- 重排块
|
||||
- 缩进层级
|
||||
- 页面引用与块引用占位
|
||||
- 复杂块占位
|
||||
|
||||
这说明“块”比“整篇富文本 JSON”更接近系统真实操作单元。
|
||||
|
||||
### 6.2 Block 建议字段
|
||||
|
||||
- `block_id`
|
||||
- `workspace_id`
|
||||
- `page_id`
|
||||
- `parent_block_id`
|
||||
- `prev_block_id`
|
||||
- `next_block_id`
|
||||
- `sort_key`
|
||||
- `block_type`
|
||||
- `content`
|
||||
- `props`
|
||||
- `annotations`
|
||||
- `status`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `created_by`
|
||||
- `updated_by`
|
||||
- `revision`
|
||||
|
||||
### 6.3 BlockType 建议
|
||||
|
||||
最小集合建议:
|
||||
|
||||
- `paragraph`
|
||||
- `heading`
|
||||
- `bulleted_list_item`
|
||||
- `numbered_list_item`
|
||||
- `todo`
|
||||
- `quote`
|
||||
- `code_block`
|
||||
- `divider`
|
||||
- `callout`
|
||||
- `page_reference`
|
||||
- `block_reference`
|
||||
- `embed_asset`
|
||||
- `embed_view`
|
||||
- `embed_onlyoffice`
|
||||
- `embed_mindmap`
|
||||
- `embed_table`
|
||||
- `system_placeholder`
|
||||
|
||||
原则:
|
||||
|
||||
- 类型应描述语义,不描述某个前端组件名
|
||||
- `embed_*` 表示“外部能力挂件”,不是事实层自己变成那个系统
|
||||
|
||||
### 6.4 Block.content
|
||||
|
||||
建议:
|
||||
|
||||
- 文本类块使用结构化 inline span 序列
|
||||
- 避免只存单纯 HTML
|
||||
- 避免直接依赖第三方编辑器私有 schema
|
||||
|
||||
建议形态:
|
||||
|
||||
```json
|
||||
{
|
||||
"spans": [
|
||||
{ "type": "text", "text": "hello" },
|
||||
{ "type": "page_ref", "page_id": "page_xxx", "title": "项目计划" },
|
||||
{ "type": "text", "text": " world" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 Block.props
|
||||
|
||||
只放结构化、有限、可校验字段,例如:
|
||||
|
||||
- `level`
|
||||
- `checked`
|
||||
- `collapsed`
|
||||
- `indent`
|
||||
- `language`
|
||||
- `align`
|
||||
- `width`
|
||||
- `height`
|
||||
- `asset_id`
|
||||
- `view_id`
|
||||
|
||||
避免把任意 UI 状态塞进 props。
|
||||
|
||||
---
|
||||
|
||||
## 7. Asset
|
||||
|
||||
`Asset` 是所有非正文主块树内容的统一挂载对象。
|
||||
|
||||
### 7.1 Asset 范围
|
||||
|
||||
包括:
|
||||
|
||||
- 图片
|
||||
- PDF
|
||||
- docx
|
||||
- xlsx
|
||||
- pptx
|
||||
- 音频
|
||||
- 视频
|
||||
- 导出文件
|
||||
- OCR 中间产物
|
||||
- 结构提取结果
|
||||
|
||||
### 7.2 建议字段
|
||||
|
||||
- `asset_id`
|
||||
- `workspace_id`
|
||||
- `storage_key`
|
||||
- `original_name`
|
||||
- `mime_type`
|
||||
- `size_bytes`
|
||||
- `checksum`
|
||||
- `origin`(upload / import / generated / synced)
|
||||
- `asset_kind`(image / pdf / office / audio / video / binary / extracted_text)
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `created_by`
|
||||
- `status`
|
||||
- `latest_version_id`
|
||||
|
||||
### 7.3 AssetVersion
|
||||
|
||||
附件应支持版本化。
|
||||
|
||||
建议:
|
||||
|
||||
- `asset_version_id`
|
||||
- `asset_id`
|
||||
- `version_no`
|
||||
- `storage_key`
|
||||
- `checksum`
|
||||
- `derived_from_version_id`
|
||||
- `created_at`
|
||||
- `created_by`
|
||||
- `change_reason`
|
||||
|
||||
这样才能支撑:
|
||||
|
||||
- OnlyOffice 编辑回写
|
||||
- OCR 重跑
|
||||
- 导入转换
|
||||
- AI 修改资产派生内容
|
||||
|
||||
---
|
||||
|
||||
## 8. Reference
|
||||
|
||||
`Reference` 是知识型系统的关键对象,不能只做正文里的临时 token。
|
||||
|
||||
### 8.1 Reference 范围
|
||||
|
||||
- 块引用块
|
||||
- 页面引用
|
||||
- 资产片段引用
|
||||
- PDF 页码引用
|
||||
- Office 书签/段落锚点引用
|
||||
- URL 引用
|
||||
- 检索结果引用
|
||||
|
||||
### 8.2 建议字段
|
||||
|
||||
- `reference_id`
|
||||
- `workspace_id`
|
||||
- `source_object_type`
|
||||
- `source_object_id`
|
||||
- `target_object_type`
|
||||
- `target_object_id`
|
||||
- `anchor`
|
||||
- `label`
|
||||
- `snippet`
|
||||
- `confidence`
|
||||
- `created_at`
|
||||
- `created_by`
|
||||
- `ref_kind`
|
||||
|
||||
### 8.3 Anchor
|
||||
|
||||
`anchor` 建议作为独立结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "pdf_page",
|
||||
"page": 12,
|
||||
"bbox": null,
|
||||
"text_quote": "..."
|
||||
}
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "office_bookmark",
|
||||
"bookmark": "Heading_3",
|
||||
"text_quote": "..."
|
||||
}
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 锚点是系统对象,不是编辑器私有游标
|
||||
- 锚点失效时要能标记 stale,而不是静默消失
|
||||
|
||||
---
|
||||
|
||||
## 9. SelectionAnchor
|
||||
|
||||
`SelectionAnchor` 不是长期事实主对象,但在 Agent / 编辑器桥接中有价值。
|
||||
|
||||
它表示:
|
||||
|
||||
- 当前选区
|
||||
- 当前光标
|
||||
- 当前活动页
|
||||
- 当前资产中的定位点
|
||||
|
||||
建议定位为:
|
||||
|
||||
- **短生命周期上下文对象**
|
||||
- 可存入 AgentSession / UI Session
|
||||
- 默认不直接作为主事实落库
|
||||
|
||||
因为:
|
||||
|
||||
- 它变化太频繁
|
||||
- 容易和编辑器宿主耦合
|
||||
- 更适合作为命令输入上下文
|
||||
|
||||
---
|
||||
|
||||
## 10. Task
|
||||
|
||||
`Task` 既是用户任务,也是 AI 工作单元。
|
||||
|
||||
### 10.1 建议字段
|
||||
|
||||
- `task_id`
|
||||
- `workspace_id`
|
||||
- `title`
|
||||
- `description`
|
||||
- `task_type`
|
||||
- `status`
|
||||
- `priority`
|
||||
- `assignee_actor_id`
|
||||
- `source_page_id`
|
||||
- `source_block_id`
|
||||
- `input_payload`
|
||||
- `output_payload`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `due_at`
|
||||
- `completed_at`
|
||||
|
||||
### 10.2 为什么纳入领域模型
|
||||
|
||||
因为 AI 长期介入后,很多能力不是即时编辑,而是:
|
||||
|
||||
- 导入任务
|
||||
- 提取任务
|
||||
- OCR 任务
|
||||
- 摘要任务
|
||||
- 索引重建任务
|
||||
- 同步任务
|
||||
|
||||
这些都应进入系统内核,而不是散在外部脚本里。
|
||||
|
||||
---
|
||||
|
||||
## 11. AgentSession
|
||||
|
||||
`AgentSession` 表示某次 AI 介入的上下文单元。
|
||||
|
||||
### 11.1 建议字段
|
||||
|
||||
- `agent_session_id`
|
||||
- `workspace_id`
|
||||
- `provider`
|
||||
- `model`
|
||||
- `initiator_actor_id`
|
||||
- `status`
|
||||
- `started_at`
|
||||
- `updated_at`
|
||||
- `ended_at`
|
||||
- `tool_policy`
|
||||
- `confirmation_policy`
|
||||
- `summary`
|
||||
|
||||
### 11.2 不应存什么
|
||||
|
||||
不应把整份聊天 UI 状态直接当作领域模型核心。
|
||||
|
||||
建议分开:
|
||||
|
||||
- 领域层只保留会话元信息、工具调用摘要、事件链路
|
||||
- 详细消息可作为附属日志或导出工件保存
|
||||
|
||||
---
|
||||
|
||||
## 12. CommandLog 与 Event
|
||||
|
||||
### 12.1 CommandLog
|
||||
|
||||
代表一次显式操作请求。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `command_id`
|
||||
- `workspace_id`
|
||||
- `command_name`
|
||||
- `actor`
|
||||
- `source`
|
||||
- `target`
|
||||
- `payload_hash`
|
||||
- `reason`
|
||||
- `refs`
|
||||
- `idempotency_key`
|
||||
- `dry_run`
|
||||
- `status`
|
||||
- `requested_at`
|
||||
- `finished_at`
|
||||
- `error_code`
|
||||
- `error_message`
|
||||
|
||||
### 12.2 Event
|
||||
|
||||
代表命令执行后产生的事实变化。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `event_id`
|
||||
- `workspace_id`
|
||||
- `command_id`
|
||||
- `event_type`
|
||||
- `object_type`
|
||||
- `object_id`
|
||||
- `before_revision`
|
||||
- `after_revision`
|
||||
- `payload`
|
||||
- `created_at`
|
||||
|
||||
原则:
|
||||
|
||||
- 命令与事件分离
|
||||
- 先有意图,再有变化
|
||||
- 审计必须能回答“谁因为什么改了什么”
|
||||
|
||||
---
|
||||
|
||||
## 13. View / Derived Object
|
||||
|
||||
以下对象建议明确标注为派生对象,不直接当真相:
|
||||
|
||||
- TOCView
|
||||
- BacklinkView
|
||||
- SearchHit
|
||||
- KnowledgeGraphView
|
||||
- MindmapProjection
|
||||
- OnlyOfficeProjection
|
||||
- PagePreview
|
||||
- DailyDigest
|
||||
|
||||
它们的共同特点:
|
||||
|
||||
- 来自事实层投影
|
||||
- 可以缓存
|
||||
- 可以失效
|
||||
- 可以重建
|
||||
|
||||
---
|
||||
|
||||
## 14. Mindmap 的正确定位
|
||||
|
||||
从既有设计看,Mindmap 很适合走结构化 `ops` 协议。
|
||||
|
||||
但在 `mnote-rust` 中,建议将其定义为:
|
||||
|
||||
- `Asset` 或 `ViewProjection` 的一种
|
||||
- 由 `Page / Block / Reference / Asset` 投影生成或承载
|
||||
- 支持独立存储其节点视图结构
|
||||
- 但不让它成为全系统唯一知识主模型
|
||||
|
||||
建议:
|
||||
|
||||
- 若是“页面内导图块”,则作为 `embed_mindmap` block 挂载
|
||||
- 若是“独立导图资产”,则作为 `Asset(kind=mindmap)`
|
||||
- AI 修改导图时,走 `MindmapOp[]`,但最终仍写入系统命令日志
|
||||
|
||||
---
|
||||
|
||||
## 15. OnlyOffice 的正确定位
|
||||
|
||||
OnlyOffice 不应被建模为“Page 本体”,应被建模为:
|
||||
|
||||
- `Asset(kind=office)`
|
||||
- `AssetVersion`
|
||||
- `OnlyOfficeAdapterSession`
|
||||
- `SelectionAnchor(kind=office_*)`
|
||||
- `Reference(anchor=office_*)`
|
||||
|
||||
也就是说:
|
||||
|
||||
- Office 文档是系统资产
|
||||
- OnlyOffice 是该资产的一种编辑适配器
|
||||
- 编辑产生新版本与事件
|
||||
- AI 可以通过插件/适配器读当前选区、插入内容、替换内容
|
||||
- 但系统真相依然在你的领域模型与资产版本链里
|
||||
|
||||
---
|
||||
|
||||
## 16. v0 必须避免的错误
|
||||
|
||||
### 16.1 不要把前端 store 当领域模型
|
||||
|
||||
例如:
|
||||
|
||||
- 当前页面 UI 选中态
|
||||
- 面板展开态
|
||||
- 聊天输入框草稿
|
||||
|
||||
这些不应进入核心领域。
|
||||
|
||||
### 16.2 不要把第三方编辑器 JSON 当系统法典
|
||||
|
||||
否则未来:
|
||||
|
||||
- 替换编辑器会非常痛苦
|
||||
- AI 只能学编辑器私有结构
|
||||
- CLI 难以稳定操作
|
||||
|
||||
### 16.3 不要把全文字符串替换当主修改方式
|
||||
|
||||
必须保留结构化修改能力。
|
||||
|
||||
### 16.4 不要把向量库结果当事实对象
|
||||
|
||||
向量结果是检索派生物,不是知识真相。
|
||||
|
||||
---
|
||||
|
||||
## 17. 一句话结论
|
||||
|
||||
`mnote-rust` 的领域模型应以 **Workspace / Page / Block / Asset / Reference / Task / AgentSession / CommandLog / Event** 为核心;
|
||||
Mindmap、OnlyOffice、搜索结果、AI 面板都只是围绕这些核心对象形成的投影、适配器或任务系统,而不能反客为主。
|
||||
@@ -0,0 +1,717 @@
|
||||
# 02. Command / Query / Tool Protocol v0
|
||||
|
||||
更新时间:2026-04-11
|
||||
适用范围:`/mnt/Data1T/mnote-rust`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本文件定义 `mnote-rust` 的统一协议面。
|
||||
|
||||
目标只有一个:
|
||||
|
||||
> 让 Web、Desktop、CLI、Agent、批处理任务都通过**同一套正式入口**操作系统。
|
||||
|
||||
它要解决的问题:
|
||||
|
||||
- 怎样避免“每个页面、每个组件、每个 API route 都有自己的写法”
|
||||
- 怎样让 AI 不依赖 UI 模拟,而能直接操作笔记软件
|
||||
- 怎样保证命令可审计、可回放、可测试、可维护
|
||||
|
||||
---
|
||||
|
||||
## 2. 基本原则
|
||||
|
||||
### 2.1 写与读必须分离
|
||||
|
||||
- **Command**:会改变事实层
|
||||
- **Query**:只读,不改变事实层
|
||||
- **Tool**:面向 AI / CLI 的能力暴露层,内部映射到 Command / Query / Job
|
||||
|
||||
### 2.2 Tool 不是第三套业务逻辑
|
||||
|
||||
禁止出现:
|
||||
|
||||
- Web 走一套逻辑
|
||||
- CLI 走一套逻辑
|
||||
- AI Tool 再写一套逻辑
|
||||
|
||||
正确关系:
|
||||
|
||||
```text
|
||||
Tool -> Command / Query / Job -> Domain + Storage + EventLog
|
||||
```
|
||||
|
||||
### 2.3 每条写命令都要可审计
|
||||
|
||||
至少要记录:
|
||||
|
||||
- 谁发起
|
||||
- 从哪里发起
|
||||
- 改了什么
|
||||
- 为什么改
|
||||
- 影响了哪些对象
|
||||
- 是否成功
|
||||
|
||||
### 2.4 AI 默认只能调用系统工具
|
||||
|
||||
AI 不应默认获得:
|
||||
|
||||
- 任意 SQL
|
||||
- 任意文件写入
|
||||
- 任意 UI 按钮点击
|
||||
- 任意内部未定型方法
|
||||
|
||||
AI 应调用:
|
||||
|
||||
- 稳定的 Tool API
|
||||
- 明确的参数模型
|
||||
- 明确的权限模型
|
||||
|
||||
---
|
||||
|
||||
## 3. 三层协议面
|
||||
|
||||
```text
|
||||
Human UI / CLI / Agent Runtime
|
||||
│
|
||||
▼
|
||||
Tool API
|
||||
│
|
||||
┌───────┴────────┐
|
||||
▼ ▼
|
||||
Query API Command API
|
||||
│ │
|
||||
└───────┬────────┘
|
||||
▼
|
||||
Job / Workflow API
|
||||
│
|
||||
▼
|
||||
Rust Note Core
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- Tool API 是面向使用者的稳定能力面
|
||||
- Command / Query API 是内核的正式操作面
|
||||
- Job / Workflow API 负责长任务、异步流程、外部能力编排
|
||||
|
||||
---
|
||||
|
||||
## 4. Actor 模型
|
||||
|
||||
所有命令、查询、工具调用都应带 `actor`。
|
||||
|
||||
建议结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": {
|
||||
"type": "human",
|
||||
"id": "user_123",
|
||||
"session_id": "ui_session_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": {
|
||||
"type": "agent",
|
||||
"id": "agent_session_456",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4-mini"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": {
|
||||
"type": "system",
|
||||
"id": "job_runner"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
- 审计
|
||||
- 权限判断
|
||||
- 限流
|
||||
- 错误追踪
|
||||
|
||||
---
|
||||
|
||||
## 5. Command API
|
||||
|
||||
## 5.1 通用结构
|
||||
|
||||
建议所有命令统一壳:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "insert_block",
|
||||
"command_id": "cmd_01",
|
||||
"idempotency_key": "idem_01",
|
||||
"actor": {
|
||||
"type": "agent",
|
||||
"id": "agent_session_1"
|
||||
},
|
||||
"source": {
|
||||
"channel": "tool",
|
||||
"client": "mcp"
|
||||
},
|
||||
"target": {
|
||||
"workspace_id": "ws_1",
|
||||
"page_id": "page_1"
|
||||
},
|
||||
"payload": {},
|
||||
"reason": "补充总结段落",
|
||||
"refs": ["ref_1"],
|
||||
"dry_run": false,
|
||||
"validate_only": false,
|
||||
"requested_at": "2026-04-11T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 通用返回
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"command_id": "cmd_01",
|
||||
"event_ids": ["evt_1", "evt_2"],
|
||||
"affected_objects": [
|
||||
{ "type": "block", "id": "block_123" }
|
||||
],
|
||||
"revision": {
|
||||
"workspace_id": "ws_1",
|
||||
"page_id": "page_1",
|
||||
"value": 42
|
||||
},
|
||||
"warnings": [],
|
||||
"rollback_hint": {
|
||||
"kind": "compensating_command",
|
||||
"command": "delete_block",
|
||||
"target_id": "block_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 观测字段约束
|
||||
|
||||
从 task-034 开始,所有正式 `Command / Query / Tool / Job` 面都要默认带上并统一解释下面这些字段:
|
||||
|
||||
- `request_id`:一次入口请求级别的稳定编号,用于串起 route、runtime、日志和回查接口
|
||||
- `trace_id`:一次完整调用链的稳定编号,用于跨 query / command / tool / job 串联同一条执行路径
|
||||
- `command_id`:写命令的唯一编号,也是 `command_log_id`、`event_id` 推导的基础
|
||||
- `idempotency_key`:CLI、AI、Web 共享的幂等语义主键,不能三端各自解释
|
||||
- `command_log_id` / `event_id`:进入审计与回放链后的稳定对象编号,供 request/trace/command 回查与恢复命令复用
|
||||
|
||||
如果某条能力不能带出这组字段,它就还不能算“可供 CLI 与 AI 稳定运行的正式协议面”。
|
||||
|
||||
### 5.3 命令分类
|
||||
|
||||
#### 页面类
|
||||
- `create_page`
|
||||
- `rename_page`
|
||||
- `move_page`
|
||||
- `archive_page`
|
||||
- `restore_page`
|
||||
- `set_page_property`
|
||||
|
||||
#### 块类
|
||||
- `insert_block`
|
||||
- `update_block`
|
||||
- `delete_block`
|
||||
- `move_block`
|
||||
- `batch_apply_block_ops`
|
||||
- `replace_block_content`
|
||||
|
||||
#### 引用类
|
||||
- `create_reference`
|
||||
- `remove_reference`
|
||||
- `rebind_reference_anchor`
|
||||
|
||||
#### 资产类
|
||||
- `attach_asset`
|
||||
- `replace_asset_version`
|
||||
- `set_asset_metadata`
|
||||
- `extract_asset_outline`
|
||||
|
||||
#### 工作区 / 系统类
|
||||
- `create_task`
|
||||
- `cancel_task`
|
||||
- `rebuild_search_index`
|
||||
- `run_import_job`
|
||||
- `run_export_job`
|
||||
|
||||
---
|
||||
|
||||
## 6. Command 设计约束
|
||||
|
||||
### 6.1 命令应小而明确
|
||||
|
||||
优先:
|
||||
|
||||
- `insert_block`
|
||||
- `move_block`
|
||||
- `set_page_property`
|
||||
|
||||
避免:
|
||||
|
||||
- `save_everything`
|
||||
- `update_editor_state`
|
||||
- `mutate_page_with_ui_payload`
|
||||
|
||||
### 6.2 支持幂等键
|
||||
|
||||
AI 可能重试、网络可能重放,因此:
|
||||
|
||||
- 所有外部写命令建议支持 `idempotency_key`
|
||||
- 同一作用域内重复提交应可去重
|
||||
|
||||
### 6.3 支持 dry-run / validate-only
|
||||
|
||||
这样 AI 可以先问:
|
||||
|
||||
- 这个操作是否合法?
|
||||
- 会影响哪些对象?
|
||||
- 是否需要确认?
|
||||
|
||||
这对 Agent 很重要。
|
||||
|
||||
### 6.4 支持批量 ops,但要有边界
|
||||
|
||||
对于 Mindmap、结构调整、导入转换,可以提供:
|
||||
|
||||
- `batch_apply_block_ops`
|
||||
- `apply_mindmap_ops`
|
||||
- `apply_document_transform_ops`
|
||||
|
||||
但要求:
|
||||
|
||||
- ops 必须结构化
|
||||
- 单条失败策略明确
|
||||
- 返回逐条结果
|
||||
|
||||
---
|
||||
|
||||
## 7. Query API
|
||||
|
||||
## 7.1 通用结构
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "page.get",
|
||||
"actor": {
|
||||
"type": "human",
|
||||
"id": "user_1"
|
||||
},
|
||||
"scope": {
|
||||
"workspace_id": "ws_1"
|
||||
},
|
||||
"params": {
|
||||
"page_id": "page_1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 查询分类
|
||||
|
||||
#### 页面与块
|
||||
- `page.get`
|
||||
- `page.tree`
|
||||
- `page.list_children`
|
||||
- `block.get`
|
||||
- `block.subtree`
|
||||
|
||||
#### 检索
|
||||
- `search.text`
|
||||
- `search.reference`
|
||||
- `search.asset`
|
||||
- `search.page_by_title`
|
||||
|
||||
#### 资产
|
||||
- `asset.get`
|
||||
- `asset.list_versions`
|
||||
- `asset.resolve_anchor`
|
||||
|
||||
#### 日志与会话
|
||||
- `command_log.list`
|
||||
- `command_log.get`
|
||||
- `event.list`
|
||||
- `agent_session.get`
|
||||
- `task.list`
|
||||
|
||||
### 7.3 输出要求
|
||||
|
||||
- 输出稳定、字段可预期
|
||||
- 支持分页、游标、过滤
|
||||
- 避免直接返回前端组件所需临时形态
|
||||
- 对大型对象支持摘要与展开模式
|
||||
|
||||
---
|
||||
|
||||
## 8. Tool API
|
||||
|
||||
## 8.1 Tool API 的职责
|
||||
|
||||
Tool API 是给:
|
||||
|
||||
- AI Agent
|
||||
- CLI
|
||||
- 自动化脚本
|
||||
- 外部集成方
|
||||
|
||||
用的能力层。
|
||||
|
||||
它的原则是:
|
||||
|
||||
- 工具名清晰
|
||||
- 参数结构明确
|
||||
- 返回格式稳定
|
||||
- 明确确认策略与权限策略
|
||||
|
||||
## 8.2 工具命名风格
|
||||
|
||||
建议按领域分组:
|
||||
|
||||
- `note.read_page`
|
||||
- `note.list_children`
|
||||
- `note.insert_block`
|
||||
- `note.update_block`
|
||||
- `note.move_block`
|
||||
- `note.search`
|
||||
- `asset.attach_file`
|
||||
- `asset.extract_outline`
|
||||
- `office.get_selection`
|
||||
- `office.replace_selection`
|
||||
- `mindmap.apply_ops`
|
||||
- `system.get_recent_commands`
|
||||
|
||||
也可以对外映射为 MCP 风格扁平命名,但内核层建议保留层级语义。
|
||||
|
||||
## 8.3 Tool 与 Command/Query 的映射
|
||||
|
||||
例如:
|
||||
|
||||
- `note.read_page` -> `page.get`
|
||||
- `note.insert_block` -> `insert_block`
|
||||
- `note.search` -> `search.text`
|
||||
- `asset.attach_file` -> `attach_asset`
|
||||
- `mindmap.apply_ops` -> `apply_mindmap_ops`
|
||||
|
||||
### 8.4 Tool 返回
|
||||
|
||||
建议统一包含:
|
||||
|
||||
- `ok`
|
||||
- `data`
|
||||
- `warnings`
|
||||
- `requires_confirmation`
|
||||
- `confirmation_reason`
|
||||
- `next_actions`
|
||||
|
||||
例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"page_id": "page_1",
|
||||
"title": "项目计划"
|
||||
},
|
||||
"warnings": [],
|
||||
"requires_confirmation": false,
|
||||
"next_actions": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Job / Workflow API
|
||||
|
||||
有些操作不是同步命令,而是长任务。
|
||||
|
||||
例如:
|
||||
|
||||
- 导入大型 PDF
|
||||
- OCR
|
||||
- 构建索引
|
||||
- 批量转换文档
|
||||
- Office 文件解析
|
||||
- 全库引用修复
|
||||
- AI 长链路整理
|
||||
|
||||
因此需要 `Job API`:
|
||||
|
||||
### 9.1 Job 通用结构
|
||||
|
||||
```json
|
||||
{
|
||||
"job": "import_document",
|
||||
"job_id": "job_1",
|
||||
"actor": { "type": "agent", "id": "agent_1" },
|
||||
"input": {
|
||||
"asset_id": "asset_1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Job 状态
|
||||
|
||||
- `queued`
|
||||
- `running`
|
||||
- `waiting_confirmation`
|
||||
- `succeeded`
|
||||
- `failed`
|
||||
- `cancelled`
|
||||
|
||||
### 9.3 Job 输出
|
||||
|
||||
- 结果对象
|
||||
- 生成的 page / asset / reference
|
||||
- 日志摘要
|
||||
- 错误详情
|
||||
|
||||
---
|
||||
|
||||
## 10. Confirmation Policy
|
||||
|
||||
不是所有工具都应直接执行。
|
||||
|
||||
建议分级:
|
||||
|
||||
### 10.1 无需确认
|
||||
|
||||
- 只读查询
|
||||
- 本地摘要生成
|
||||
- 小范围插入草稿块
|
||||
|
||||
### 10.2 建议确认
|
||||
|
||||
- 批量删除
|
||||
- 批量重排
|
||||
- 覆盖资产版本
|
||||
- 导出到外部位置
|
||||
|
||||
### 10.3 强制确认
|
||||
|
||||
- 大范围页面删除
|
||||
- 全库重建/迁移
|
||||
- 覆盖性导入
|
||||
- 对外同步/发布
|
||||
|
||||
确认策略应由内核或 policy 层判断,不能完全由前端决定。
|
||||
|
||||
---
|
||||
|
||||
## 11. Error Model
|
||||
|
||||
错误必须结构化。
|
||||
|
||||
建议格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "BLOCK_NOT_FOUND",
|
||||
"message": "目标块不存在",
|
||||
"details": {
|
||||
"block_id": "block_xxx"
|
||||
},
|
||||
"retryable": false,
|
||||
"suggested_action": "请先刷新页面树或重新读取块子树"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
分类建议:
|
||||
|
||||
- `VALIDATION_ERROR`
|
||||
- `PERMISSION_DENIED`
|
||||
- `NOT_FOUND`
|
||||
- `CONFLICT`
|
||||
- `STALE_REVISION`
|
||||
- `RATE_LIMITED`
|
||||
- `EXTERNAL_ADAPTER_ERROR`
|
||||
- `INTERNAL_ERROR`
|
||||
|
||||
这样 AI 才能根据错误做下一步,而不是只看到一段字符串。
|
||||
|
||||
---
|
||||
|
||||
## 12. Revision / Concurrency
|
||||
|
||||
### 12.1 为什么必须有 revision
|
||||
|
||||
AI 与人可能同时修改。
|
||||
|
||||
如果没有 revision:
|
||||
|
||||
- 很容易覆盖彼此内容
|
||||
- 工具重试会写乱
|
||||
- 无法做冲突判断
|
||||
|
||||
### 12.2 建议
|
||||
|
||||
- `Page` 级 revision
|
||||
- `Block` 级 revision
|
||||
- `AssetVersion` 级 version_no
|
||||
|
||||
命令可支持:
|
||||
|
||||
- `expected_revision`
|
||||
- `on_conflict` 策略(fail / merge_if_possible / create_patch)
|
||||
|
||||
---
|
||||
|
||||
## 13. OnlyOffice 协议边界
|
||||
|
||||
OnlyOffice 不应直接暴露为“让 AI 操控 iframe”。
|
||||
|
||||
建议拆成两层:
|
||||
|
||||
### 13.1 Office Adapter Query
|
||||
|
||||
- `office.get_active_asset`
|
||||
- `office.get_selection`
|
||||
- `office.get_current_page`
|
||||
- `office.list_bookmarks`
|
||||
|
||||
### 13.2 Office Adapter Command
|
||||
|
||||
- `office.insert_text`
|
||||
- `office.replace_selection`
|
||||
- `office.insert_comment`
|
||||
- `office.save_as_new_version`
|
||||
- `office.extract_outline`
|
||||
|
||||
原则:
|
||||
|
||||
- Tool 面暴露的是“Office 文档能力”
|
||||
- 不是浏览器点击坐标或 iframe DOM
|
||||
- 若插件不可用,应返回结构化不可用错误
|
||||
|
||||
---
|
||||
|
||||
## 14. Mindmap 协议边界
|
||||
|
||||
继续保留既有设计里最有价值的部分:`ops`。
|
||||
|
||||
建议:
|
||||
|
||||
- `mindmap.get`
|
||||
- `mindmap.apply_ops`
|
||||
- `mindmap.expand_node`
|
||||
- `mindmap.attach_refs`
|
||||
|
||||
其中 `mindmap.apply_ops` 入参可以继续沿用:
|
||||
|
||||
- `addChild`
|
||||
- `addSiblingAfter`
|
||||
- `updateText`
|
||||
- `setHyperlink`
|
||||
- `setRefs`
|
||||
- `appendNote`
|
||||
- `deleteNode`
|
||||
|
||||
但要求:
|
||||
|
||||
- 所有调用都写入统一命令日志
|
||||
- mindmap 不再走单独一套野生落盘体系
|
||||
|
||||
---
|
||||
|
||||
## 15. CLI 协议面
|
||||
|
||||
CLI 不是开发附属品,而是系统正式壳层。
|
||||
|
||||
建议 CLI 优先覆盖:
|
||||
|
||||
- `mnote page get <page-id>`
|
||||
- `mnote page tree`
|
||||
- `mnote block insert`
|
||||
- `mnote block move`
|
||||
- `mnote search text`
|
||||
- `mnote asset attach`
|
||||
- `mnote office selection`
|
||||
- `mnote job run import-document`
|
||||
- `mnote log recent`
|
||||
|
||||
CLI 应满足:
|
||||
|
||||
- 输出 JSON 模式
|
||||
- 退出码稳定
|
||||
- 适合 shell / AI / 自动化脚本调用
|
||||
|
||||
---
|
||||
|
||||
## 16. MCP / Agent Runtime 暴露面
|
||||
|
||||
如果后续提供 MCP:
|
||||
|
||||
- MCP 工具层应直接包装 Tool API
|
||||
- 不应重新发明一套独立业务逻辑
|
||||
- 返回值尽量和 CLI JSON 输出接近
|
||||
|
||||
这样好处是:
|
||||
|
||||
- Web agent、桌面 agent、本地 Hermes/Codex 都共用一套系统能力
|
||||
- 文档里只需维护一份工具协议
|
||||
|
||||
---
|
||||
|
||||
## 17. 最小 v0 清单
|
||||
|
||||
建议 v0 最先固化以下协议:
|
||||
|
||||
### Command
|
||||
- `create_page`
|
||||
- `rename_page`
|
||||
- `insert_block`
|
||||
- `update_block`
|
||||
- `delete_block`
|
||||
- `move_block`
|
||||
- `attach_asset`
|
||||
- `create_reference`
|
||||
|
||||
### Query
|
||||
- `page.get`
|
||||
- `page.tree`
|
||||
- `block.subtree`
|
||||
- `search.text`
|
||||
- `asset.get`
|
||||
- `command_log.list`
|
||||
|
||||
### Tool
|
||||
- `note.read_page`
|
||||
- `note.insert_block`
|
||||
- `note.update_block`
|
||||
- `note.move_block`
|
||||
- `note.search`
|
||||
- `asset.attach_file`
|
||||
- `system.get_recent_commands`
|
||||
|
||||
这套足以先打通:
|
||||
|
||||
- 人类基础编辑
|
||||
- CLI 基础操作
|
||||
- AI 基础介入
|
||||
- 审计与回放链路
|
||||
|
||||
---
|
||||
|
||||
## 18. 一句话结论
|
||||
|
||||
`mnote-rust` 的协议设计应坚持:
|
||||
|
||||
> **Command 负责改,Query 负责读,Tool 负责给 AI/CLI 用,Job 负责长流程;所有入口最终收敛到同一 Rust 内核,不再允许页面私有接口、编辑器私有接口、临时胶水接口长期并存。**
|
||||
@@ -0,0 +1,622 @@
|
||||
# 03. Storage / Event / Indexing v0
|
||||
|
||||
更新时间:2026-04-11
|
||||
适用范围:`/mnt/Data1T/mnote-rust`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本文件定义 `mnote-rust` 在当前路线下的持久化、事件日志、索引体系。
|
||||
|
||||
它要回答:
|
||||
|
||||
- 当前系统事实层应落在哪里
|
||||
- Rust 内核怎样接入既有事实层而不重复造库
|
||||
- 写操作如何进入事件链路
|
||||
- 搜索、引用、RAG、回放、审计依赖什么数据面
|
||||
- 如何既保证性能,又保证 AI 可观测、可维护、可回放
|
||||
|
||||
这部分的前提必须先讲清:
|
||||
|
||||
> 对当前 `mnote` / `mnote-next` 主线来说,**Convex 仍是主事实层**。
|
||||
|
||||
因此,这份文档不再讨论“用 SQLite 取代 Convex 做主库”,而是讨论:
|
||||
|
||||
- 如何在 **Convex 主事实层** 之上建立 Rust 内核
|
||||
- 如何把索引、事件、导出、离线缓存、本地处理组织清楚
|
||||
- 如何避免再次引入第二套事实真相
|
||||
|
||||
如果这层设计不好,系统最终仍会退化成:
|
||||
|
||||
- 页面组件自己维护真相
|
||||
- 各类能力各写各的缓存
|
||||
- AI 看不到完整上下文
|
||||
- Rust 内核与现有系统各管一套数据
|
||||
- 回滚、审计、重建索引都变得困难
|
||||
|
||||
---
|
||||
|
||||
## 2. 总原则
|
||||
|
||||
### 2.1 主事实层、事件日志、索引必须分层
|
||||
|
||||
三者职责不同:
|
||||
|
||||
- **主事实层**:保存系统当前真相
|
||||
- **事件日志**:保存“如何变成现在”的过程
|
||||
- **索引层**:为检索、聚合、推荐、RAG 提供加速读模型
|
||||
|
||||
禁止让任一层越权:
|
||||
|
||||
- 不能拿索引当真相
|
||||
- 不能只靠事件流而没有可直接读取的当前状态
|
||||
- 不能让页面缓存成为事实层
|
||||
- 不能让本地缓存演化成第二主库
|
||||
|
||||
### 2.2 当前主事实层应继续落在 Convex
|
||||
|
||||
基于 `mnote` 与 `mnote-next` 已有主线,当前阶段应坚持:
|
||||
|
||||
- **Convex 是唯一主事实层**
|
||||
- Rust 内核通过协议与 bridge 接入 Convex,而不是绕开它再建一套主库
|
||||
- 旧仓与新仓共享的核心对象,应优先复用既有 Convex schema / deployment / caller 体系
|
||||
|
||||
这不是保守,而是避免推翻已经跑通的主链路。
|
||||
|
||||
### 2.3 本地文件系统与本地嵌入式存储仍然重要
|
||||
|
||||
虽然主事实层继续用 Convex,但本地层仍然需要:
|
||||
|
||||
- **文件系统**:资产原文件、导出文件、缓存、派生产物、临时工作目录
|
||||
- **可选本地嵌入式存储**:离线缓存、索引快照、dry-run、调试态数据、临时队列
|
||||
|
||||
但这些都不能升级为新的主事实层。
|
||||
|
||||
### 2.4 写入必须原子化到“主事实层 + 事件落账”
|
||||
|
||||
一次命令成功后,至少要保证:
|
||||
|
||||
- 主事实层写入完成
|
||||
- 事件写入成功
|
||||
- 命令日志有记录
|
||||
|
||||
索引允许异步追平,但必须可检测 lag。
|
||||
|
||||
### 2.5 索引必须可重建
|
||||
|
||||
搜索索引、向量索引、聚合视图都必须满足:
|
||||
|
||||
- 可从主事实层 + 事件重新构建
|
||||
- 可做全量重建
|
||||
- 可做增量追平
|
||||
|
||||
否则后期会不可维护。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三层数据面
|
||||
|
||||
```text
|
||||
[主事实层]
|
||||
Convex + FileSystem
|
||||
|
||||
[事件与日志层]
|
||||
CommandLog + DomainEvent + JobLog + AuditTrail
|
||||
|
||||
[索引与派生层]
|
||||
FTS / Reference Graph / Backlink View / Outline View / Vector Index
|
||||
```
|
||||
|
||||
### 3.1 主事实层负责
|
||||
|
||||
- Workspace / Page / Block / Asset / Reference / Task / AgentSession 当前状态
|
||||
- 事务性写入
|
||||
- 读取当前真相
|
||||
- 提供稳定 schema
|
||||
- 承担权限与主对象约束
|
||||
|
||||
### 3.2 事件与日志层负责
|
||||
|
||||
- 记录命令与事件
|
||||
- 记录 actor、reason、scope、结果
|
||||
- 支持回放、审计、问题追踪
|
||||
- 作为索引增量更新输入
|
||||
|
||||
### 3.3 索引与派生层负责
|
||||
|
||||
- 全文搜索
|
||||
- 页面大纲
|
||||
- 反链
|
||||
- 资产片段搜索
|
||||
- RAG 检索读模型
|
||||
- 推荐 / 关系聚合
|
||||
|
||||
---
|
||||
|
||||
## 4. 主事实层选型
|
||||
|
||||
## 4.1 当前阶段的明确结论
|
||||
|
||||
推荐:
|
||||
|
||||
- **Convex**:结构化主事实层
|
||||
- **File System**:存原始资产与大对象
|
||||
- **Rust bridge / protocol layer**:作为统一命令、查询、工具接入面
|
||||
|
||||
原因:
|
||||
|
||||
- 与 `mnote` 中“Convex 已完全替换 Supabase”的现实一致
|
||||
- 与 `mnote-next` 中“默认复用现有 Convex deployment / project”的路线一致
|
||||
- 复用现有 deployment、schema、自建与 AI 开发经验,避免重建第二套数据库主线
|
||||
- 更符合“复用成熟能力,重构边界,不做无意义重建”的迁移原则
|
||||
|
||||
## 4.2 本地嵌入式存储的正确定位
|
||||
|
||||
可以保留本地嵌入式存储,但只限于:
|
||||
|
||||
- CLI / Agent 的离线缓存
|
||||
- 全文索引引擎的本地数据文件
|
||||
- dry-run / scaffold / 调试态数据
|
||||
- 单机导入处理中的临时工作库
|
||||
|
||||
不能把它写成:
|
||||
|
||||
- 第二套主事实层
|
||||
- 与 Convex 并列的正式写入真相
|
||||
- 长期双写的核心业务库
|
||||
|
||||
## 4.3 不建议的中心方案
|
||||
|
||||
- 用 SQLite 取代 Convex 成为主真相
|
||||
- OnlyOffice / 第三方编辑器内部状态作为主真相
|
||||
- 仅事件溯源、无当前态表
|
||||
- 单纯 Markdown 文件散落 + 大量 sidecar 作为唯一结构源
|
||||
- 长期维护 Convex + SQLite 双事实源
|
||||
|
||||
这些都会让系统再次陷入边界混乱。
|
||||
|
||||
---
|
||||
|
||||
## 5. 主事实对象结构建议
|
||||
|
||||
这里讨论的是**领域对象结构**,不是要求新建第二套数据库。
|
||||
|
||||
## 5.1 结构原则
|
||||
|
||||
- 主对象必须能映射到现有 Convex schema
|
||||
- 单对象一组稳定字段或有限关联表
|
||||
- 避免过度 EAV
|
||||
- JSON 仅用于局部扩展字段,不替代主 schema
|
||||
|
||||
## 5.2 建议主对象
|
||||
|
||||
至少包含:
|
||||
|
||||
- `workspaces`
|
||||
- `pages`
|
||||
- `page_versions`(可选)
|
||||
- `blocks`
|
||||
- `block_relations`(可选,若不全放在 block 表中)
|
||||
- `assets`
|
||||
- `asset_versions`
|
||||
- `references`
|
||||
- `tasks`
|
||||
- `agent_sessions`
|
||||
- `command_logs`
|
||||
- `domain_events`
|
||||
- `jobs`
|
||||
- `job_logs`
|
||||
|
||||
这些对象应优先映射或扩展到现有 Convex 主线,而不是在新仓先落一套平行本地表。
|
||||
|
||||
## 5.3 blocks 对象建议
|
||||
|
||||
关键字段:
|
||||
|
||||
- `block_id`
|
||||
- `workspace_id`
|
||||
- `page_id`
|
||||
- `parent_block_id`
|
||||
- `sort_key`
|
||||
- `block_type`
|
||||
- `content_json`
|
||||
- `props_json`
|
||||
- `annotations_json`
|
||||
- `revision`
|
||||
- `deleted_at`
|
||||
|
||||
说明:
|
||||
|
||||
- `sort_key` 建议支持稀疏排序,避免频繁全量重排
|
||||
- 软删除优于直接硬删,方便审计与恢复
|
||||
- `revision` 用于乐观并发控制
|
||||
|
||||
## 5.4 assets 对象建议
|
||||
|
||||
- `asset_id`
|
||||
- `workspace_id`
|
||||
- `asset_kind`
|
||||
- `mime_type`
|
||||
- `current_version_id`
|
||||
- `storage_strategy`
|
||||
- `status`
|
||||
- `metadata_json`
|
||||
|
||||
## 5.5 references 对象建议
|
||||
|
||||
- `reference_id`
|
||||
- `source_object_type`
|
||||
- `source_object_id`
|
||||
- `target_object_type`
|
||||
- `target_object_id`
|
||||
- `anchor_json`
|
||||
- `ref_kind`
|
||||
- `status`
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件系统布局建议
|
||||
|
||||
资产不要全塞数据库 blob。
|
||||
|
||||
建议:
|
||||
|
||||
```text
|
||||
workspace-data/
|
||||
├─ assets/
|
||||
│ ├─ asset_xxx/
|
||||
│ │ ├─ v1/original.docx
|
||||
│ │ ├─ v2/original.docx
|
||||
│ │ ├─ extracted/outline.json
|
||||
│ │ ├─ extracted/text.md
|
||||
│ │ └─ derived/preview.png
|
||||
├─ indexes/
|
||||
│ ├─ fts/
|
||||
│ └─ vector/
|
||||
├─ runtime/
|
||||
│ ├─ jobs/
|
||||
│ ├─ sessions/
|
||||
│ └─ temp/
|
||||
└─ export/
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 结构化真相仍以 Convex 为主
|
||||
- 原始大文件入文件系统
|
||||
- 派生产物有明确目录归属
|
||||
- 临时文件与正式资产分离
|
||||
|
||||
---
|
||||
|
||||
## 7. Command Log
|
||||
|
||||
`CommandLog` 是所有写命令的正式记录。
|
||||
|
||||
### 7.1 最低字段
|
||||
|
||||
- `command_log_id`
|
||||
- `command_name`
|
||||
- `actor_type`
|
||||
- `actor_id`
|
||||
- `source`
|
||||
- `workspace_id`
|
||||
- `target_objects`
|
||||
- `payload_summary`
|
||||
- `refs_json`
|
||||
- `idempotency_key`
|
||||
- `status`
|
||||
- `created_at`
|
||||
- `finished_at`
|
||||
|
||||
### 7.2 设计要求
|
||||
|
||||
- 能关联到一次真实主写入
|
||||
- 能关联后续 domain events
|
||||
- 能关联 tool call / job / rollback
|
||||
- 能为 AI 回放与人类审计提供最小闭包
|
||||
|
||||
### 7.3 与 Convex 的关系
|
||||
|
||||
命令日志可以:
|
||||
|
||||
- 直接进入 Convex 主线对象
|
||||
- 或通过 bridge 落到与主对象同一事实层
|
||||
|
||||
但不要单独把命令日志只记在本地、主对象却记在远端;那会破坏统一审计链路。
|
||||
|
||||
---
|
||||
|
||||
## 8. Domain Event
|
||||
|
||||
`DomainEvent` 不是为了炫技,而是为了:
|
||||
|
||||
- 给索引层提供标准增量输入
|
||||
- 给回放和审计提供结构化事件
|
||||
- 给异步任务提供稳定订阅源
|
||||
|
||||
### 8.1 事件最低字段
|
||||
|
||||
- `event_id`
|
||||
- `workspace_id`
|
||||
- `aggregate_type`
|
||||
- `aggregate_id`
|
||||
- `event_type`
|
||||
- `event_version`
|
||||
- `payload_json`
|
||||
- `command_log_id`
|
||||
- `actor_type`
|
||||
- `created_at`
|
||||
|
||||
### 8.2 事件边界
|
||||
|
||||
建议记录领域事件,而不是 UI 手势。
|
||||
|
||||
例如:
|
||||
|
||||
- `page_created`
|
||||
- `page_renamed`
|
||||
- `block_inserted`
|
||||
- `block_moved`
|
||||
- `block_content_replaced`
|
||||
- `asset_version_added`
|
||||
- `reference_created`
|
||||
- `task_status_changed`
|
||||
|
||||
不建议记录:
|
||||
|
||||
- 弹窗打开
|
||||
- 光标移动
|
||||
- hover 展示
|
||||
- 面板折叠
|
||||
|
||||
---
|
||||
|
||||
## 9. Job 与异步链路
|
||||
|
||||
不是所有事情都应进主事务。
|
||||
|
||||
应把这些放入 Job:
|
||||
|
||||
- OCR
|
||||
- OnlyOffice 提取/转换
|
||||
- 向量切片与嵌入
|
||||
- 全量重建索引
|
||||
- 大文件导入
|
||||
- 外部同步
|
||||
|
||||
### 9.1 Job 最低字段
|
||||
|
||||
- `job_id`
|
||||
- `job_type`
|
||||
- `workspace_id`
|
||||
- `target_objects`
|
||||
- `input_json`
|
||||
- `status`
|
||||
- `progress`
|
||||
- `error`
|
||||
- `created_at`
|
||||
- `started_at`
|
||||
- `finished_at`
|
||||
|
||||
### 9.2 不能放进主事务的内容
|
||||
|
||||
- 大模型推理
|
||||
- OCR
|
||||
- 大文件转换
|
||||
- 向量重建
|
||||
- 远程同步
|
||||
|
||||
这些必须走 Job。
|
||||
|
||||
---
|
||||
|
||||
## 10. 索引体系
|
||||
|
||||
## 10.1 Full Text Search
|
||||
|
||||
最低必做:
|
||||
|
||||
- 页面标题全文检索
|
||||
- 块内容全文检索
|
||||
- 资产提取文本全文检索
|
||||
- 引用 snippet 检索
|
||||
|
||||
建议:
|
||||
|
||||
- 初期直接用本地全文索引引擎(如 SQLite FTS5、Tantivy 或兼容方案)
|
||||
- 索引文档单位统一为“可定位对象”
|
||||
|
||||
例如:
|
||||
|
||||
- page
|
||||
- block
|
||||
- asset_fragment
|
||||
- reference_snippet
|
||||
|
||||
### 10.2 为什么块级索引重要
|
||||
|
||||
因为你的产品最终不是“整篇文档搜索”,而是:
|
||||
|
||||
- 找到某一段
|
||||
- 跳回块位置
|
||||
- 让 AI 只编辑局部
|
||||
- 给引用与上下文最小闭包
|
||||
|
||||
## 10.3 Reference Graph
|
||||
|
||||
必须维护引用图读模型:
|
||||
|
||||
- page -> page
|
||||
- block -> block
|
||||
- block -> asset fragment
|
||||
- task -> source object
|
||||
|
||||
这对:
|
||||
|
||||
- 反链
|
||||
- 影响面分析
|
||||
- AI 上下文组装
|
||||
- 知识导航
|
||||
|
||||
都很关键。
|
||||
|
||||
## 10.4 Outline View
|
||||
|
||||
页面大纲、Office 文档提取大纲、PDF 目录、Mindmap 树都应是派生读模型。
|
||||
|
||||
优点:
|
||||
|
||||
- 不污染主事实层
|
||||
- 允许多种提取策略
|
||||
- 容易重建
|
||||
|
||||
## 10.5 Vector Index
|
||||
|
||||
向量索引建议延后,但结构上预留。
|
||||
|
||||
原则:
|
||||
|
||||
- 向量索引只是检索加速层
|
||||
- 向量 chunk 必须能回指 `page_id / block_id / asset_id / anchor`
|
||||
- 向量索引失效可重建,不影响系统主真相
|
||||
|
||||
---
|
||||
|
||||
## 11. 索引更新策略
|
||||
|
||||
## 11.1 起步建议:异步增量 + 可全量重建
|
||||
|
||||
每次事件提交后:
|
||||
|
||||
- 生成索引任务
|
||||
- 按对象粒度增量更新
|
||||
- 记录 lag 与最后处理 event id
|
||||
|
||||
## 11.2 索引一致性要求
|
||||
|
||||
- 主事实层一致性 > 索引实时性
|
||||
- 查询可返回“索引处理中”状态
|
||||
- 对必须强一致的局部场景,可同步刷新小范围索引
|
||||
|
||||
## 11.3 失败恢复
|
||||
|
||||
索引 worker 挂了也不能影响主写入。
|
||||
|
||||
必须支持:
|
||||
|
||||
- 从 `last_processed_event_id` 继续追平
|
||||
- 指定对象重建
|
||||
- 指定 workspace 全量重建
|
||||
|
||||
### 11.4 回放与索引重建的正式入口
|
||||
|
||||
从 task-034 开始,回放和重建不再只是“库里有个纯函数”,而要成为统一命令面的一部分。
|
||||
|
||||
最小要求:
|
||||
|
||||
- `event_replay`:从统一事件列表读取,输出 replay 后的 `cursor` 与受影响 event 摘要
|
||||
- `index_rebuild`:基于 `last_processed_event_id` / `last_processed_at` 重建索引批次,并返回新的 cursor
|
||||
- 两者都复用同一套 `request_id`、`trace_id`、`workspace_id`、`command_id` 语义,不允许临时脚本私有解释
|
||||
|
||||
这意味着后续无论是 CLI、AI 还是 Web 排障,都应先调用同一条 Rust 恢复命令面,而不是各自写一套索引补数脚本。
|
||||
|
||||
---
|
||||
|
||||
## 12. AI 可观测性
|
||||
|
||||
AI 全流程介入后,系统必须能回答:
|
||||
|
||||
- 某次回答引用了哪些对象
|
||||
- 某次编辑基于哪些上下文
|
||||
- 某个工具为什么失败
|
||||
- 当前搜索索引是否滞后
|
||||
- 哪些对象正在被长任务处理
|
||||
|
||||
因此建议额外维护:
|
||||
|
||||
### 12.1 AgentSession 关联表
|
||||
|
||||
- `agent_session_objects`
|
||||
- `agent_session_tool_calls`
|
||||
- `agent_session_outputs`
|
||||
|
||||
### 12.2 Tool Call Log
|
||||
|
||||
记录:
|
||||
|
||||
- tool name
|
||||
- input
|
||||
- output summary
|
||||
- duration
|
||||
- error
|
||||
- command_log_id / query trace id
|
||||
|
||||
这样后期 AI 排障、回放、评估都会容易很多。
|
||||
|
||||
---
|
||||
|
||||
## 13. 性能关键点
|
||||
|
||||
### 13.1 不要整页全文重写
|
||||
|
||||
编辑块时,只更新相关块与局部派生视图。
|
||||
|
||||
### 13.2 不要同步重建全索引
|
||||
|
||||
任何全库重建都必须后台化。
|
||||
|
||||
### 13.3 资产处理走异步
|
||||
|
||||
OCR、Office 转换、预览图生成、向量切片都不能阻塞主编辑事务。
|
||||
|
||||
### 13.4 读写模型分离
|
||||
|
||||
高频 UI 读需求可通过派生视图优化,不要污染主事实 schema。
|
||||
|
||||
---
|
||||
|
||||
## 14. OnlyOffice 在存储层的正确位置
|
||||
|
||||
OnlyOffice 相关对象建议这样落地:
|
||||
|
||||
- 原始 `docx/xlsx/pptx/pdf`:作为 `Asset` + `AssetVersion`
|
||||
- 文档编辑结果:生成新 `AssetVersion`
|
||||
- 提取结构(目录、批注、书签、文本片段):作为派生读模型或 `Reference`
|
||||
- 当前选区、当前页码:作为短生命周期 `SelectionAnchor`
|
||||
|
||||
不要把:
|
||||
|
||||
- OnlyOffice 内部文档状态
|
||||
- 插件瞬时 UI 状态
|
||||
- callback 的临时 payload
|
||||
|
||||
直接当成主事实层。
|
||||
|
||||
---
|
||||
|
||||
## 15. 迁移建议
|
||||
|
||||
先做:
|
||||
|
||||
1. 盘点现有 Convex schema 与 `mnote-rust` 领域对象的映射
|
||||
2. 落 `CommandLog / DomainEvent` 的主事实层方案
|
||||
3. 落 `Page / Block / Asset / Reference` 的 bridge / repo 接口
|
||||
4. 建立全文索引最小实现
|
||||
5. 建立索引 worker 骨架
|
||||
|
||||
再做:
|
||||
|
||||
6. AgentSession / ToolCallLog
|
||||
7. Outline / Backlink 读模型
|
||||
8. 向量索引占位
|
||||
9. Office / OCR / Mindmap 派生索引
|
||||
10. 必要的离线缓存 / dry-run 本地层
|
||||
|
||||
---
|
||||
|
||||
## 16. 一句话结论
|
||||
|
||||
`mnote-rust` 当前应采用 **Convex 主事实层 + FileSystem 资产存储 + CommandLog/DomainEvent 事件链路 + 可重建全文/引用/向量索引** 的分层结构;
|
||||
Rust 内核负责把命令、查询、工具、索引、异步任务与外部能力重新组织清楚,而不是再造一套与现有主线并列的数据库事实源。
|
||||
@@ -0,0 +1,491 @@
|
||||
# 04. OnlyOffice Integration Boundary v0
|
||||
|
||||
更新时间:2026-04-11
|
||||
适用范围:`/mnt/Data1T/mnote-rust`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本文件定义 `mnote-rust` 中 OnlyOffice 的正式边界。
|
||||
|
||||
它要解决的问题:
|
||||
|
||||
- OnlyOffice 在系统里到底是什么
|
||||
- 可以接多深
|
||||
- 哪些能力该放进插件/编辑器侧
|
||||
- 哪些能力必须留在 Rust 内核
|
||||
- 官方现状已经支持到哪一步,哪些不是猜想
|
||||
|
||||
---
|
||||
|
||||
## 2. 基于官方资料的确定事实
|
||||
|
||||
以下判断基于 ONLYOFFICE 官方文档与官方 API 文档,而不是推测。
|
||||
|
||||
### 2.1 ONLYOFFICE 已有官方 AI 插件能力
|
||||
|
||||
官方文档已明确:
|
||||
|
||||
- ONLYOFFICE 提供 **AI 插件**
|
||||
- 可接入多种模型提供商,例如 **OpenAI、DeepSeek**
|
||||
- 支持的能力包括但不限于:
|
||||
- 文本生成
|
||||
- 文本编辑
|
||||
- 总结
|
||||
- 自动创建宏
|
||||
- 面向编辑器的 AI 辅助操作
|
||||
|
||||
这说明:
|
||||
|
||||
> OnlyOffice 不是一个“完全不懂 AI 的富文档编辑器”,而是已经具备官方 AI 扩展体系。
|
||||
|
||||
### 2.2 ONLYOFFICE 已有插件系统
|
||||
|
||||
官方文档明确支持:
|
||||
|
||||
- 自定义插件
|
||||
- 插件 UI 集成
|
||||
- 插件调用外部服务
|
||||
- 插件与编辑器文档内容交互
|
||||
- 插件注册、安装、配置与运行
|
||||
|
||||
这说明后续你完全可以:
|
||||
|
||||
- 做自有 AI 插件
|
||||
- 做面向 mnote-rust 的桥接插件
|
||||
- 把系统命令面引入编辑器环境
|
||||
|
||||
### 2.3 Office JS API 已能做深度文档操作
|
||||
|
||||
官方 API 文档与样例表明,插件/脚本可进行:
|
||||
|
||||
- 获取文档对象 `Api.GetDocument()`
|
||||
- 获取当前选区/范围
|
||||
- 获取文本内容
|
||||
- 插入文本
|
||||
- 替换内容
|
||||
- 创建段落
|
||||
- 插入内容控件
|
||||
- 处理表格、图片、表单等对象
|
||||
|
||||
这意味着:
|
||||
|
||||
> “AI 在 OnlyOffice 内获取当前选区并改写当前文档片段”这条能力链,官方已经支持。
|
||||
|
||||
### 2.4 ONLYOFFICE 支持自建部署与集成
|
||||
|
||||
官方文档明确把 ONLYOFFICE Docs 作为可集成到自有系统中的 office suite / Docs API / 插件宿主来提供。
|
||||
|
||||
结合你当前仓库里已有:
|
||||
|
||||
- Docker 自建 `onlyoffice/documentserver`
|
||||
- callback / proxy / 外网地址改写
|
||||
- 前端侧 OnlyOffice 工具桥
|
||||
|
||||
可以判断:
|
||||
|
||||
> 自建部署这条路是成立的,而且值得继续做。
|
||||
|
||||
---
|
||||
|
||||
## 3. 由这些事实推出的边界判断
|
||||
|
||||
## 3.1 能做深集成,但不能反客为主
|
||||
|
||||
结论:
|
||||
|
||||
- **OnlyOffice 适合深集成**
|
||||
- **OnlyOffice 不适合做系统主内核**
|
||||
|
||||
原因不是它能力不够,而是职责不同。
|
||||
|
||||
OnlyOffice 擅长:
|
||||
|
||||
- 编辑 office 文档
|
||||
- 处理复杂版式文档
|
||||
- 承载编辑器插件与文档内 AI
|
||||
- 管理编辑器态的操作命令
|
||||
|
||||
但它不擅长天然承担:
|
||||
|
||||
- 笔记块树真相
|
||||
- 全局页面树真相
|
||||
- 跨页面引用网络真相
|
||||
- AI 全局任务编排
|
||||
- 统一命令总线
|
||||
- 全系统事件日志中心
|
||||
|
||||
## 3.2 在 mnote-rust 中的正式定位
|
||||
|
||||
OnlyOffice 应定位为:
|
||||
|
||||
> **Office Asset Editor Adapter + Office AI Subsystem**
|
||||
|
||||
而不是:
|
||||
|
||||
- 主页面编辑器内核
|
||||
- 主数据模型
|
||||
- 主命令入口
|
||||
- 主任务系统
|
||||
|
||||
---
|
||||
|
||||
## 4. 正式职责划分
|
||||
|
||||
## 4.1 OnlyOffice 负责什么
|
||||
|
||||
### A. Office 资产编辑
|
||||
|
||||
负责编辑:
|
||||
|
||||
- `docx`
|
||||
- `xlsx`
|
||||
- `pptx`
|
||||
- 部分 `pdf` 相关工作流
|
||||
|
||||
### B. 编辑器内局部 AI 能力
|
||||
|
||||
例如:
|
||||
|
||||
- 基于当前选区总结
|
||||
- 重写/润色选区文本
|
||||
- 插入生成内容
|
||||
- 提取 action items
|
||||
- 生成标题/批注/宏
|
||||
|
||||
### C. 编辑器上下文采集
|
||||
|
||||
例如:
|
||||
|
||||
- 当前文档 ID
|
||||
- 当前资产版本 ID
|
||||
- 当前选区
|
||||
- 当前页/书签/活动对象
|
||||
- 编辑器保存状态
|
||||
|
||||
### D. 将编辑结果回写主系统
|
||||
|
||||
例如:
|
||||
|
||||
- 保存为新 `AssetVersion`
|
||||
- 产出书签/目录/片段锚点
|
||||
- 触发提取文本与索引任务
|
||||
|
||||
## 4.2 Rust 内核负责什么
|
||||
|
||||
### A. 主事实层
|
||||
|
||||
- Workspace
|
||||
- Page
|
||||
- Block
|
||||
- Asset
|
||||
- AssetVersion
|
||||
- Reference
|
||||
- Task
|
||||
- Event
|
||||
- CommandLog
|
||||
|
||||
### B. 系统级命令与权限
|
||||
|
||||
- 谁可以编辑哪个资产
|
||||
- 谁可以覆盖哪个版本
|
||||
- 哪些操作需要确认
|
||||
- 哪些任务需要后台执行
|
||||
|
||||
### C. 全局 AI 调度
|
||||
|
||||
- Agent Session
|
||||
- Tool Registry
|
||||
- CLI / MCP / Tool API
|
||||
- 长任务编排
|
||||
- 审计与回放
|
||||
|
||||
### D. 跨资产与跨页面知识组织
|
||||
|
||||
- 引用图
|
||||
- 搜索索引
|
||||
- 反链
|
||||
- RAG
|
||||
- 页面/块/资产统一导航
|
||||
|
||||
---
|
||||
|
||||
## 5. 最推荐的集成方式
|
||||
|
||||
## 5.1 推荐结构
|
||||
|
||||
```text
|
||||
AI / CLI / Web / Desktop
|
||||
│
|
||||
▼
|
||||
Rust Command / Query / Tool API
|
||||
│
|
||||
├─ Asset Service
|
||||
├─ Agent Service
|
||||
├─ Search / Reference Service
|
||||
└─ OnlyOffice Adapter Service
|
||||
│
|
||||
├─ Docs API
|
||||
├─ Callback Handler
|
||||
├─ Plugin Bridge
|
||||
└─ Selection / Save / Anchor Bridge
|
||||
```
|
||||
|
||||
核心含义:
|
||||
|
||||
- 所有人和 AI 都先找 Rust 内核
|
||||
- Rust 内核再协调 OnlyOffice
|
||||
- OnlyOffice 不是上帝,只是一个能力域
|
||||
|
||||
## 5.2 不推荐结构
|
||||
|
||||
```text
|
||||
AI -> OnlyOffice Plugin -> 直接改系统数据库/页面状态
|
||||
```
|
||||
|
||||
或者:
|
||||
|
||||
```text
|
||||
Web 页面逻辑 -> 直接控制 OnlyOffice -> 顺手写一部分业务真相
|
||||
```
|
||||
|
||||
这种结构会再次回到胶水冲突。
|
||||
|
||||
---
|
||||
|
||||
## 6. 官方 AI 能力在新系统中的用法
|
||||
|
||||
## 6.1 可以直接利用的部分
|
||||
|
||||
可以利用官方插件/Office API 做:
|
||||
|
||||
- `office.get_selection`
|
||||
- `office.get_document_text`
|
||||
- `office.replace_selection`
|
||||
- `office.insert_after_selection`
|
||||
- `office.create_comment_from_ai`
|
||||
- `office.generate_outline`
|
||||
- `office.extract_action_items`
|
||||
|
||||
这些能力适合做成:
|
||||
|
||||
- 插件内命令
|
||||
- 本地桥接命令
|
||||
- Tool API 的 office 子域工具
|
||||
|
||||
## 6.2 不应依赖的部分
|
||||
|
||||
不应把以下能力外包给 OnlyOffice:
|
||||
|
||||
- 页面树管理
|
||||
- 笔记块模型
|
||||
- 全局知识链接
|
||||
- Agent 全局工作记忆
|
||||
- 系统统一任务编排
|
||||
- 跨类型对象权限控制
|
||||
|
||||
---
|
||||
|
||||
## 7. 推荐的 Tool API 分工
|
||||
|
||||
## 7.1 Rust 内核对外工具
|
||||
|
||||
例如:
|
||||
|
||||
- `asset.open_in_office`
|
||||
- `asset.list_versions`
|
||||
- `asset.commit_new_version`
|
||||
- `reference.resolve_anchor`
|
||||
- `office.create_edit_session`
|
||||
- `office.get_capabilities`
|
||||
|
||||
## 7.2 OnlyOffice 插件桥工具
|
||||
|
||||
例如:
|
||||
|
||||
- `office_plugin.get_selection`
|
||||
- `office_plugin.get_document_text`
|
||||
- `office_plugin.replace_selection`
|
||||
- `office_plugin.insert_text`
|
||||
- `office_plugin.get_outline`
|
||||
- `office_plugin.list_bookmarks`
|
||||
|
||||
## 7.3 正确的调用链
|
||||
|
||||
```text
|
||||
Agent / CLI
|
||||
-> note/asset/office Tool API
|
||||
-> Rust 内核
|
||||
-> OnlyOffice Adapter / Plugin Bridge
|
||||
-> Office JS API / Docs API
|
||||
-> 返回结构化结果
|
||||
```
|
||||
|
||||
而不是:
|
||||
|
||||
```text
|
||||
Agent 直接知道 OnlyOffice 内部细节并自行编排保存流程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 版本回写策略
|
||||
|
||||
这是边界里最关键的一块。
|
||||
|
||||
### 8.1 正确做法
|
||||
|
||||
OnlyOffice 编辑完成后:
|
||||
|
||||
- 生成新的 `AssetVersion`
|
||||
- 更新 `assets.current_version_id`
|
||||
- 写 `command_log` / `event`
|
||||
- 触发:
|
||||
- 文本提取
|
||||
- 大纲提取
|
||||
- 锚点更新
|
||||
- 索引更新
|
||||
|
||||
### 8.2 不正确做法
|
||||
|
||||
- 只在编辑器里“看起来改了”但系统无版本记录
|
||||
- callback 到了就直接覆盖文件,不产生日志
|
||||
- 直接把插件临时状态写成业务真相
|
||||
|
||||
---
|
||||
|
||||
## 9. Anchor / 选区 / 书签策略
|
||||
|
||||
OnlyOffice 的一个真正价值,是它能提供相对更强的文档内部定位。
|
||||
|
||||
建议区分两类定位:
|
||||
|
||||
### 9.1 短期编辑上下文
|
||||
|
||||
例如:
|
||||
|
||||
- 当前选区
|
||||
- 当前光标
|
||||
- 当前页
|
||||
- 当前激活对象
|
||||
|
||||
这类只作为:
|
||||
|
||||
- `SelectionAnchor`
|
||||
- AgentSession 上下文
|
||||
- Tool 输入
|
||||
|
||||
### 9.2 长期可引用锚点
|
||||
|
||||
例如:
|
||||
|
||||
- bookmark
|
||||
- 标题路径
|
||||
- 批注 id
|
||||
- 内容控件 id
|
||||
- 提取片段 hash
|
||||
|
||||
这类才适合进正式 `Reference.anchor`。
|
||||
|
||||
原则:
|
||||
|
||||
- 选区是瞬时上下文
|
||||
- 书签/内容控件/稳定片段才是长期引用对象
|
||||
|
||||
---
|
||||
|
||||
## 10. 插件开发建议
|
||||
|
||||
## 10.1 建议做自有插件桥
|
||||
|
||||
理由:
|
||||
|
||||
- 官方插件体系已经成熟到可用
|
||||
- 你需要的是“把 OnlyOffice 接进自己的命令系统”
|
||||
- 自有桥插件比零散页面 hack 更稳
|
||||
|
||||
## 10.2 插件职责建议
|
||||
|
||||
插件只做:
|
||||
|
||||
- 获取编辑器上下文
|
||||
- 执行局部编辑命令
|
||||
- 请求 mnote-rust Tool API
|
||||
- 接收结构化结果并落到文档
|
||||
- 回传选择/书签/状态
|
||||
|
||||
插件不要做:
|
||||
|
||||
- 自己维护主业务状态
|
||||
- 自己做全局权限判断
|
||||
- 自己做全局任务调度
|
||||
- 自己缓存长期知识图谱
|
||||
|
||||
---
|
||||
|
||||
## 11. 源码部署 / 自建部署的实际建议
|
||||
|
||||
## 11.1 结论
|
||||
|
||||
**值得继续推进自建部署。**
|
||||
|
||||
## 11.2 原因
|
||||
|
||||
- 网络、callback、鉴权策略可控
|
||||
- 便于把插件、配置、回调、存储策略收归自己管理
|
||||
- 更方便未来与桌面版、本地模式、离线模式衔接
|
||||
- 减少外部部署差异导致的集成复杂度
|
||||
|
||||
## 11.3 但不建议的误区
|
||||
|
||||
- 不要把“自建部署”误解为“去重写编辑器引擎”
|
||||
- 不要把“插件可改内容”误解为“OnlyOffice 可以替代整个笔记内核”
|
||||
- 不要把“官方已有 AI 插件”误解为“你的 Agent 架构可以省掉”
|
||||
|
||||
---
|
||||
|
||||
## 12. 对 mnote-rust 的直接影响
|
||||
|
||||
这份结论会直接影响后续架构文档:
|
||||
|
||||
1. `office.*` 作为独立工具域存在
|
||||
2. `AssetVersion` 必须是一等对象
|
||||
3. `Reference.anchor` 必须支持 office bookmark / content control / text quote
|
||||
4. `SelectionAnchor` 只做短期上下文,不升格为主事实
|
||||
5. Agent 与 OnlyOffice 的关系必须经由 Rust Tool API,而不是页面硬连
|
||||
|
||||
---
|
||||
|
||||
## 13. 最小落地路线
|
||||
|
||||
### Phase A
|
||||
|
||||
- 自建 ONLYOFFICE Docs 持续使用
|
||||
- 补正式 adapter 文档
|
||||
- 补 `AssetVersion` / callback / save 流程
|
||||
|
||||
### Phase B
|
||||
|
||||
- 做自有桥插件
|
||||
- 打通选区读取、文本替换、结构化命令执行
|
||||
- 统一插件与系统鉴权
|
||||
|
||||
### Phase C
|
||||
|
||||
- 把 office 工具域接入统一 Tool API / CLI / Agent Runtime
|
||||
- 让 AI 通过系统命令操作 Office 资产
|
||||
- 建立锚点、版本、索引、审计闭环
|
||||
|
||||
### Phase D
|
||||
|
||||
- 再评估是否要更深度定制 UI / 插件体验
|
||||
- 但不进入“重写 OnlyOffice 内核”路线
|
||||
|
||||
---
|
||||
|
||||
## 14. 一句话结论
|
||||
|
||||
基于 ONLYOFFICE 官方当前能力,最合理的路线不是回避它,也不是围绕它建系统,而是:
|
||||
|
||||
> **把 ONLYOFFICE 作为可自建、可插件化、可 AI 深接入的 Office 资产编辑子系统;同时把主事实层、命令层、日志层、任务层牢牢放在 Rust 内核里。**
|
||||
Reference in New Issue
Block a user