收口 Rust Web 入口与 AI 写入链
- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。 - 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。 - 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。 - 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
@@ -41,6 +41,13 @@ pub struct DocumentShellQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentPageCompatQuery {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn document_page_shell(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -82,6 +89,8 @@ pub async fn document_page_shell(
|
||||
let workspace_name = workspace_projection.workspace_name.clone();
|
||||
let page_subtree_json =
|
||||
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
|
||||
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
|
||||
.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 body_content = crate::ssr::render_view(leptos::view! {
|
||||
@@ -93,6 +102,7 @@ pub async fn document_page_shell(
|
||||
workspace_name={workspace_name}
|
||||
workspace_sidebar_html={workspace_sidebar_html}
|
||||
page_subtree_json={page_subtree_json}
|
||||
page_options_json={page_options_json}
|
||||
/>
|
||||
});
|
||||
let html = format!(
|
||||
@@ -198,9 +208,9 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
}
|
||||
if (!documentId) return;
|
||||
const escapedId = cssEscape(documentId);
|
||||
setText(`.tree-row[data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
||||
setText(`.tree-row[data-document-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
||||
setText(`.tree-row[data-doc-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
||||
const escapedDocRowId = cssEscape(`doc:${documentId}`);
|
||||
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
|
||||
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, title);
|
||||
setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
|
||||
setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
|
||||
setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
|
||||
@@ -307,8 +317,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return '';
|
||||
};
|
||||
|
||||
const legacyBlockToTiptap = (block) => {
|
||||
const legacyBlockToTiptap = (block, index = 0) => {
|
||||
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
|
||||
const blockId = typeof block?.id === 'string' && block.id.trim()
|
||||
? block.id.trim()
|
||||
: typeof block?.blockId === 'string' && block.blockId.trim()
|
||||
? block.blockId.trim()
|
||||
: `block-${index + 1}`;
|
||||
const text = flattenText(block?.content);
|
||||
const content = text ? [{ type: 'text', text }] : [];
|
||||
const textAlign = typeof block?.props?.textAlign === 'string'
|
||||
@@ -318,15 +333,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: undefined;
|
||||
const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs;
|
||||
const nestedChildren = Array.isArray(block?.children)
|
||||
? block.children.map(legacyBlockToTiptap).filter(Boolean)
|
||||
? block.children.map((child, childIndex) => legacyBlockToTiptap(child, childIndex)).filter(Boolean)
|
||||
: [];
|
||||
const withListChildren = (itemType, listType, attrs = {}) => ({
|
||||
type: listType,
|
||||
attrs: { blockId, ...attrs },
|
||||
content: [{
|
||||
type: itemType,
|
||||
attrs,
|
||||
attrs: { blockId },
|
||||
content: [
|
||||
{ type: 'paragraph', content },
|
||||
{ type: 'paragraph', attrs: { blockId }, content },
|
||||
...nestedChildren,
|
||||
],
|
||||
}],
|
||||
@@ -334,7 +350,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (type === 'heading') {
|
||||
const level = Number(block?.props?.level || block?.level || 1) || 1;
|
||||
const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {};
|
||||
return { type: 'heading', attrs: withTextAlign({ level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
|
||||
return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
|
||||
}
|
||||
if (type === 'bulletListItem' || type === 'bullet_list_item') {
|
||||
return withListChildren('listItem', 'bulletList');
|
||||
@@ -346,13 +362,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) });
|
||||
}
|
||||
if (type === 'quote' || type === 'blockquote') {
|
||||
return { type: 'blockquote', attrs: withTextAlign(), content: [{ type: 'paragraph', attrs: withTextAlign(), content }] };
|
||||
return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] };
|
||||
}
|
||||
if (type === 'codeBlock' || type === 'code_block') {
|
||||
return { type: 'codeBlock', attrs: withTextAlign({ language: block?.props?.language || null }), content };
|
||||
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
|
||||
}
|
||||
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') {
|
||||
return { type: 'horizontalRule' };
|
||||
return { type: 'horizontalRule', attrs: { blockId } };
|
||||
}
|
||||
if (type === 'table') {
|
||||
const tableSnapshot = block?.props?.tiptapTable;
|
||||
@@ -395,9 +411,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
alt: block?.props?.alt || block?.alt || null,
|
||||
title: block?.props?.title || block?.title || null,
|
||||
};
|
||||
return attrs.src ? { type: 'image', attrs } : null;
|
||||
return attrs.src ? { type: 'image', attrs: { blockId, ...attrs } } : null;
|
||||
}
|
||||
return { type: 'paragraph', attrs: withTextAlign(), content };
|
||||
return { type: 'paragraph', attrs: withTextAlign({ blockId }), content };
|
||||
};
|
||||
|
||||
const textToTiptapDocument = (text) => ({
|
||||
@@ -436,6 +452,102 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return textToTiptapDocument(fallbackText);
|
||||
};
|
||||
|
||||
const inlineTextNodes = (node) => {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
if (Array.isArray(node.content)) {
|
||||
return node.content.flatMap((child) => {
|
||||
if (child?.type === 'text') {
|
||||
const text = typeof child.text === 'string' ? child.text : '';
|
||||
return text ? [{ type: 'text', text }] : [];
|
||||
}
|
||||
if (child?.type === 'hardBreak') {
|
||||
return [{ type: 'text', text: '\n' }];
|
||||
}
|
||||
return inlineTextNodes(child);
|
||||
});
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const firstChild = (node) => Array.isArray(node?.content) ? node.content[0] : null;
|
||||
const blockIdOf = (node, index) => {
|
||||
const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : '';
|
||||
return raw || `block-${index + 1}`;
|
||||
};
|
||||
|
||||
const tiptapNodeToEditorBlock = (node, index) => {
|
||||
const blockId = blockIdOf(node, index);
|
||||
if (node?.type === 'paragraph') {
|
||||
return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'heading') {
|
||||
const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1));
|
||||
return { blockId, blockType: 'heading', props: { headingLevel: level }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'bulletList') {
|
||||
return { blockId, blockType: 'bullet_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'orderedList') {
|
||||
return { blockId, blockType: 'numbered_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'taskList') {
|
||||
const taskItem = firstChild(node);
|
||||
return { blockId, blockType: 'todo', props: { checked: Boolean(taskItem?.attrs?.checked) }, contentNodes: inlineTextNodes(firstChild(taskItem)), childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'blockquote') {
|
||||
return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'codeBlock') {
|
||||
return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'horizontalRule') {
|
||||
return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'image') {
|
||||
return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'tocNode') {
|
||||
return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] };
|
||||
}
|
||||
if (node?.type === 'table') {
|
||||
return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const editorDocumentFromTiptapDocument = (tiptapDocument) => {
|
||||
const content = Array.isArray(tiptapDocument?.content) ? tiptapDocument.content : [];
|
||||
const blocks = content.map(tiptapNodeToEditorBlock).filter(Boolean);
|
||||
return {
|
||||
documentId: bootstrap.documentId,
|
||||
rootBlockIds: blocks.map((block) => block.blockId),
|
||||
blocks,
|
||||
};
|
||||
};
|
||||
|
||||
const legacyBlocksFromEditorDocument = (editorDocument) => (
|
||||
Array.isArray(editorDocument?.blocks) ? editorDocument.blocks : []
|
||||
).map((block) => ({
|
||||
id: block.blockId,
|
||||
type: block.blockType,
|
||||
props: block.blockType === 'heading'
|
||||
? { level: block.props?.headingLevel || 1 }
|
||||
: block.blockType === 'todo'
|
||||
? { checked: Boolean(block.props?.checked) }
|
||||
: block.blockType === 'code_block'
|
||||
? { language: block.props?.language || null }
|
||||
: block.blockType === 'image'
|
||||
? { ...(block.props || {}) }
|
||||
: block.blockType === 'toc'
|
||||
? { ...(block.props || {}) }
|
||||
: block.blockType === 'table'
|
||||
? { ...(block.props || {}) }
|
||||
: undefined,
|
||||
content: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')
|
||||
: '',
|
||||
}));
|
||||
|
||||
const pageBody = aggregate.body || {};
|
||||
const permissions = aggregate.head?.permissions || {};
|
||||
const conflictDetectionKey = typeof pageBody.conflictDetectionKey === 'string'
|
||||
@@ -502,6 +614,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
setStatus('saved');
|
||||
return;
|
||||
}
|
||||
const editorDocument = editorDocumentFromTiptapDocument(tiptapDocument);
|
||||
const content = legacyBlocksFromEditorDocument(editorDocument);
|
||||
setStatus('saving');
|
||||
const response = await fetch(bootstrap.saveEndpoint || '/api/documents/save', {
|
||||
method: 'POST',
|
||||
@@ -511,9 +625,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
workspaceId: bootstrap.workspaceId,
|
||||
revision: editorMeta.revision,
|
||||
conflictDetectionKey: editorMeta.conflictDetectionKey,
|
||||
content: [],
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument,
|
||||
blockCount: null,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
}),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
@@ -526,6 +641,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key;
|
||||
if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey;
|
||||
lastSavedSerialized = serialized;
|
||||
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
|
||||
window.__mnoteRecordPageHistorySnapshot('save', {
|
||||
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
|
||||
characterCount: currentEditorText().replace(/\s/g, '').length,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length,
|
||||
todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length,
|
||||
});
|
||||
}
|
||||
setStatus('saved');
|
||||
};
|
||||
|
||||
@@ -566,6 +690,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const mountId = runtime.mount(root, mountOptions);
|
||||
root.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
||||
if (typeof window.__mnoteApplyPageOptionsToShell === 'function') {
|
||||
window.__mnoteApplyPageOptionsToShell();
|
||||
}
|
||||
setStatus('ready');
|
||||
};
|
||||
|
||||
@@ -668,6 +795,47 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn documents_page_compat(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<DocumentPageCompatQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let document_id = query.document_id.trim().to_string();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
&state,
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let projection_owner = aggregate.source_label();
|
||||
let mut response = (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"schema": "mnote.documents_page_compat.v1",
|
||||
"page": aggregate,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
stamp_shell_headers(response.headers_mut(), "documents-page-compat");
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
|
||||
if let Ok(value) = HeaderValue::from_str(projection_owner) {
|
||||
response.headers_mut().insert(name, value);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn page_aggregate(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1059,9 +1227,13 @@ mod tests {
|
||||
assert!(html.contains("data-page-title-input=\"true\""));
|
||||
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
|
||||
assert!(html.contains("mnote.document_title_controller.v1"));
|
||||
assert!(html
|
||||
.contains(".tree-row[data-node-id=\"${escapedId}\"] > .tree-link > .tree-link-title"));
|
||||
assert!(html.contains(
|
||||
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
|
||||
));
|
||||
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
|
||||
assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
|
||||
assert!(html.contains("data-mnote-action=\"open-page-settings\""));
|
||||
assert!(html.contains("data-mnote-action=\"open-page-ai\""));
|
||||
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
|
||||
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
|
||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||
|
||||
Reference in New Issue
Block a user