feat: restore Wolai workspace navigation and assets

This commit is contained in:
Agent Board
2026-07-15 23:12:15 +08:00
parent 31a160fcc5
commit 6f9c7d3b58
48 changed files with 6734 additions and 517 deletions
@@ -0,0 +1,409 @@
# 7-75 [process] Knowledge RAG UI 面板优化 v1
> 创建时间:2026-07-13
>
> 状态:`PROCESS`
>
> Owner`07-ai`
>
> 范围:3000 主壳中的知识库 / RAG / OCR 状态面板、FileTree 知识库状态灯、当前页面附件索引入口、Page AI 对知识库状态的可见性。
>
> 上位依据:
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md`
> - `/mnt/Data1T/mnote/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md`
## 1. 第一结论
当前更高优先级不是先恢复旧本地 OCR sidecar,而是把 **Knowledge RAG UI 面板** 做成用户能理解和操作的资料库控制面。
原因:
-`/api/local-folder/ocr/*` 已退役,当前图片 / PDF / Office OCR 与资料库问答主线统一走 Knowledge RAG provider。
- LightRAG 本地服务已具备图片 VLM / MinerU 路由能力,但用户在 MNote 里看不到“这张图是否已索引 / 是否已有 OCR 文本 / 是否失败 / 是否过期”。
- FileTree 目前只有 `indexed / indexing / failed` 三态,缺少 `not_indexed / stale / ocr_text_exposed / provider_unavailable` 等用户可行动状态。
- Page AI 能直接看本地图片,但长期知识库和非多模态模型仍需要 OCR / sidecar / indexUI 必须先让用户知道当前资源处于哪种状态。
因此,本设计把“图片插入后 OCR 能力增强”拆成 Knowledge RAG 面板的一部分,而不是重开旧 OCR 入口。
## 2. 当前证据
### 2.1 已存在能力
- Knowledge RAG source 支持 `md / pdf / docx / pptx / xlsx / csv / png / jpg / jpeg / webp / gif / bmp / tif / tiff` 等文件。
- `/api/knowledge-rag/ingest` 已能接收 `rootUri + sources[]`,注册 source,创建 LightRAG input symlink,并触发 provider scan。
- FileTree 行模型已带 `indexStatus`,前端可渲染 `indexed / indexing / failed`
- 旧 OCR sidecar frontmatter 读取 helper 仍在,可作为历史兼容读取,不作为新任务入口。
- LightRAG health 能返回 provider 配置、pipeline 状态、VLM 是否启用、MinerU / parser routing 等诊断信息。
### 2.2 已确认缺口
- 新插入图片只写为 Markdown 图片引用和本地文件,不会自动登记为 Knowledge RAG source。
- 未索引图片没有明确 UI 状态,用户只能看到文件存在,无法知道是否可被文本模型/RAG 检索。
- Knowledge RAG registry 中存在大量 stale / provider_missing / failed 历史记录,但 UI 缺少 doctor 视图解释和引导。
- 当前“索引到默认知识库”入口藏在 FileTree 右键菜单,缺少当前页面级的“索引本页附件”入口。
- 失败原因没有被产品化展示,例如 provider API key 不一致、provider missing、chunk 超时、parser 失败。
- OCR 结果没有在图片资源 tab 或页面侧栏中形成可读 preview;用户不知道 OCR 文本是否真正暴露给 AI。
- Page AI target package 不应恢复旧 `ocrContext`,但需要一个轻量 `resourceOcrStatus` 告诉 agent 当前资源是否已有可查 OCR / 是否应调用 Knowledge RAG / 是否需要多模态兜底。
## 3. 目标体验
Knowledge RAG 面板应回答用户四个问题:
1. **资料库是否可用**:provider 是否在线、当前默认 provider 是谁、队列是否忙。
2. **哪些资源已进入知识库**:当前 workspace / 当前页面 / 当前文件夹下的 source 状态。
3. **图片/PDF/Office 是否已 OCR/解析**:是否已索引、是否有真实 OCR 文本、是否只是图片占位符。
4. **下一步能做什么**:索引、重试、强制重建、打开引用、查看 OCR 摘要、查看失败原因。
目标 UI 不是 debug 面板,而是资料库操作面板。Raw provider payload 只放到折叠诊断区。
## 4. 非目标
- 不恢复 `/api/local-folder/ocr/*` 作为主动 OCR API。
- 不把旧 `.ocr` sidecar 重新变成页面正文真相。
- 不让 MNote 复制 LightRAG 的 chunk / vector / graph 真相。
- 不在普通页面打开、FileTree 渲染、Markdown 编辑保存中硬依赖 LightRAG 可用性。
- 不自动清理 registry / 删除 provider 索引;任何清理、删除、强制重建都必须用户确认。
## 5. 信息架构
### 5.1 面板入口
保留当前知识库入口,但改成四区布局:
```text
知识库
Provider 状态
当前页面资源
Workspace Sources
诊断与修复
```
入口位置:
- Sidebar 顶部资料库 / 搜索附近保留主入口。
- FileTree 右键保留“索引到知识库”,但成功后跳转或高亮 Knowledge RAG 面板对应 source。
- 文档页资源 tab 对图片/PDF/Office 显示“资料库状态”轻量按钮。
### 5.2 Provider 状态区
显示:
- provider 名称:`LightRAG / WeKnora / RAGFlow / legacy fallback`
- health:在线、不可达、API key 错误、pipeline busy。
- dashboard 链接。
- VLM/OCR 能力:图片解析是否启用、MinerU/Docling/native 路由摘要。
- 队列状态:queued/running/failed/retry required。
对用户文案:
- 在线:`知识库服务可用`
- 不可达:`知识库服务不可用,页面仍可编辑;AI 将只能读取本地文件或多模态图片`
- API key 错误:`MNote 与 provider 的 API key 不一致,请检查配置`
- 队列忙:`正在解析资料,稍后会自动刷新状态`
### 5.3 当前页面资源区
针对当前 Markdown 页面解析图片、PDF、Office、mindmap、附件引用,展示:
| 列 | 含义 |
|----|------|
| 资源 | 文件名、类型、路径 |
| 状态 | 未索引 / 排队中 / 索引中 / 已索引 / 失败 / 已过期 |
| OCR | 不适用 / 待解析 / 已有文本 / 只有占位 / 失败 |
| 操作 | 索引、重试、强制重建、打开资源、查看 OCR |
当前页面区必须提供批量动作:
- `索引本页附件`
- `重试失败项`
- `重建已过期项`
### 5.4 Workspace Sources 区
展示 registry 中当前 actor 可见的 sources
| 列 | 含义 |
|----|------|
| Source | root-relative path |
| 类型 | markdown / image / pdf / office |
| Provider | LightRAG / WeKnora / RAGFlow |
| 状态 | registry + provider status 合并结果 |
| 更新时间 | registry updatedAt / provider updatedAt |
| 引用 | open_reference 可否定位 |
默认过滤:
- 隐藏已删除完成项。
- stale / failed 单独汇总到顶部。
- 测试 fixture 或历史 provider_missing 只在诊断区展开显示。
### 5.5 OCR / 解析预览区
点击图片 source 后,右侧或下方展示:
- 原图预览。
- OCR / parsed text preview。
- `ocrTextExposed`:是否返回了真实文本,而不是 Markdown 图片占位符。
- sidecar / blocks 路径只在诊断展开时显示。
- `复制 OCR 文本``用 OCR 问 AI``打开引用位置`
如果没有 OCR 文本:
```text
这张图片尚未解析成文本。多模态模型可以临时读图,但文本模型和知识库检索需要先索引。
```
如果只有图片占位:
```text
provider 只返回了图片占位符,未暴露 OCR 正文。建议重试解析或改用 MinerU/VLM parser。
```
## 6. 状态模型
### 6.1 Source index status
后端和前端统一使用以下状态:
- `not_indexed`:本地资源存在,但 registry 无有效 entry。
- `queued`:已提交,但 provider 尚未开始。
- `indexing`provider processing / parsing / submitted。
- `indexed`provider doc id 存在且 indexedAt 有效。
- `failed`provider failed / provider_missing / delete_retry_required。
- `stale`source hash/mtime 变化,已有索引过期。
- `deleting`delete submitted。
- `deleted`delete completed。
FileTree 可以继续只显示少量灯号,但 tooltip / 面板必须展示完整状态。
### 6.2 OCR status
图片、扫描 PDF、Office 附件需要额外 OCR/解析状态:
- `not_applicable`:纯文本 Markdown 等无需 OCR。
- `not_started`:未提交解析。
- `processing`:解析中。
- `text_exposed`:引用/sidecar 中已有真实文本。
- `placeholder_only`:只返回图片占位符,没有 OCR 正文。
- `failed`:解析失败。
- `stale`:源文件变化后 OCR 过期。
`text_exposed` 的判断不得只看 provider “成功”,必须看 `contentDiagnostics.ocrTextExposed` 或 sidecar meaningful block 数。
## 7. 后端合同调整
### 7.1 Status API
扩展 `/api/knowledge-rag/status?rootUri=...` 返回:
```json
{
"sourceSummary": {
"total": 0,
"notIndexed": 0,
"indexing": 0,
"indexed": 0,
"failed": 0,
"stale": 0,
"ocrTextExposed": 0,
"placeholderOnly": 0
},
"sources": [
{
"sourceRootRelativePath": "docs/photo.jpg",
"resourceType": "image",
"indexStatus": "indexed",
"ocrStatus": "text_exposed",
"ocrTextPreview": "前 300 字...",
"failureMessage": null,
"actions": ["open", "reindex", "view_ocr"]
}
]
}
```
### 7.2 Current page assets API
新增或复用 Page Aggregate 输出当前页资源引用,面板拿到:
```json
{
"pageId": "local-md:...",
"sourceRootRelativePath": "docs/Page.md",
"assets": [
{
"href": "photo.jpg",
"sourceRootRelativePath": "docs/photo.jpg",
"resourceType": "image",
"exists": true,
"indexStatus": "not_indexed"
}
]
}
```
这一步不要让前端重复解析 Markdown 真相;优先从 Page Aggregate / kernel projection 输出。
### 7.3 Ingest response
`/api/knowledge-rag/ingest` 当前返回 configured / scan 等信息,面板需要稳定字段:
- `submittedSources[]`
- `skippedSources[]`
- `retryRequired`
- `providerErrorCode`
- `userMessage`
- `nextPollAfterMs`
### 7.4 Registry doctor
增加只读 doctor endpoint 或 status 子对象:
```json
{
"doctor": {
"staleEntries": 58,
"providerMissingEntries": 58,
"failedEntries": 1,
"apiKeyMismatchSuspected": true,
"suggestedActions": ["check_provider_config", "retry_failed", "archive_stale_test_entries"]
}
}
```
清理动作必须单独确认,不在 status 读取时自动执行。
## 8. 前端交互
### 8.1 FileTree 灯号
灯号含义:
- 无灯:不支持索引或用户未开启显示。
- 空心灰点:支持索引但未索引。
- 黄点:排队/索引中。
- 绿点:已索引且 OCR 文本暴露。
- 蓝点:已索引但该资源不需要 OCR。
- 红点:失败。
- 橙点:过期。
FileTree 行已有 `data-index-status`,需要扩展为 `data-index-status` + `data-ocr-status`tooltip 显示完整原因。
### 8.2 图片插入后
图片插入完成后:
1. 保存图片文件。
2. Markdown 写入图片引用。
3. UI 立刻把该资源标记为 `not_indexed`
4. 如果用户开启“自动索引新图片”,后台提交 `/api/knowledge-rag/ingest`
5. 默认不开启自动索引时,显示轻量提示:`图片已插入,可索引后用于文本检索和非多模态 AI`
自动索引应是 workspace/user preference,不是硬编码默认。
### 8.3 Page AI 协同
Page AI 不恢复旧 `ocrContext`,改用轻量状态:
```json
{
"resourceOcrStatus": {
"currentPageAssets": [
{
"sourceRootRelativePath": "docs/photo.jpg",
"resourceType": "image",
"indexStatus": "not_indexed",
"ocrStatus": "not_started",
"recommendedReadMode": "multimodal_fallback"
}
]
}
}
```
AI 行为:
- 多模态模型:可以临时读图,但应提示“未索引,无法长期检索”。
- 文本模型:如果未 OCR,应建议用户索引图片;不要假装已读图。
- 已 OCR:优先调用 `mnote.knowledge_rag.query` / `section_context`,必要时再多模态核验。
## 9. 实施步骤
### Phase 1:只读状态面板
- [ ] 统一 Knowledge RAG provider 文案,避免 LightRAG / WeKnora / RAGFlow legacy fallback 在用户界面混乱。
- [ ] 扩展 `/api/knowledge-rag/status` 的 source summary 与 registry doctor。
- [ ] 新建 Knowledge RAG 面板基础 UI,显示 provider health、source summary、失败/stale 汇总。
- [ ] FileTree tooltip 展示当前 `indexStatus`,不改变索引行为。
### Phase 2:当前页面资源闭环
- [ ] 从 Page Aggregate / projection 输出当前页附件清单。
- [ ] 面板展示“当前页面资源”列表。
- [ ] 支持“索引本页附件”“重试失败项”。
- [ ] 图片插入后刷新当前页面资源区并标记 `not_indexed`
### Phase 3OCR 预览与诊断
- [ ] 后端为 image/PDF/Office source 暴露 `ocrStatus``ocrTextPreview`
- [ ] 图片资源 tab 增加 OCR/资料库状态按钮。
- [ ] 面板支持 OCR preview、placeholder-only 诊断、失败原因展示。
- [ ] Page AI target package 增加轻量 `resourceOcrStatus`
### Phase 4Doctor 与配置修复
- [ ] 增加 registry doctor UI。
- [ ] 检测 MNote 读取的 LightRAG API key 与 provider 实际认证失败时的差异,给出明确配置建议。
- [ ] 对 stale/provider_missing 历史记录提供“归档测试记录”建议,但执行前必须确认。
- [ ] 增加失败重试与强制重建的确认流程。
## 10. 验收标准
- 插入一张图片后,当前页面资源区能立即显示该图片为 `未索引`
- 对该图片点击“索引”后,UI 进入 `索引中`,并能在 provider 完成后转成 `已索引``失败`
- 图片如果 provider 只返回 Markdown 图片占位,UI 必须显示 `未暴露 OCR 正文`,不能显示成 OCR 成功。
- 图片 OCR 成功后,用户能在面板看到 OCR 预览,并能让 Page AI 基于 Knowledge RAG 查询引用。
- provider 不可达或 API key 错误时,页面编辑不受影响,面板显示明确诊断。
-`.ocr` sidecar 不作为普通页面进入 PageTree;历史 sidecar 只作为兼容资源打开。
- 非多模态模型面对未 OCR 图片时,不声称已读取图片内容。
## 11. 测试计划
### Rust
- `cargo test -p mnote-web knowledge_rag -- --test-threads=1`
- 增加 `knowledge_rag_status_reports_not_indexed_page_assets`
- 增加 `knowledge_rag_status_reports_ocr_text_exposed_only_for_meaningful_sidecar`
- 增加 `knowledge_rag_registry_doctor_reports_provider_missing_without_cleanup`
- 增加 `page_ai_target_package_includes_resource_ocr_status_without_ocr_context`
### Browser smoke
- 新增 `scripts/task8xx-knowledge-rag-panel-status-smoke.js`
- 登录 `mnote-e2e`
- 打开含图片页面
- 验证面板显示 provider health、当前页面图片、未索引状态
- 新增 `scripts/task8xx-knowledge-rag-image-index-flow-smoke.js`
- 用测试图片触发索引
- 验证 FileTree 状态灯从未索引到索引中
- provider 可用时验证 OCR previewprovider 不可用时验证失败诊断
- 更新 Page AI smoke
- 文本模型未 OCR 图片:提示需要索引
- 多模态模型可临时读图,但回答标记未长期索引
## 12. 风险
- LightRAG provider 解析大文件可能慢或超时;UI 必须将失败原因产品化,不能只显示“索引失败”。
- 自动索引新图片可能消耗 VLM / MinerU 成本;默认建议关闭,由 workspace preference 控制。
- registry 历史测试数据较多,doctor 只能提示,不应自动删除。
- provider 名称当前存在历史口径漂移;面板第一阶段必须先统一用户可见命名。
## 13. Definition of Done
- 用户在 MNote 内不用打开终端,就能判断当前图片/PDF/Office 是否已进入知识库、是否有 OCR 文本、是否失败、是否过期。
- 用户可以从当前页面一键索引本页附件,并看到可追踪状态。
- Page AI 能基于状态选择:直接读本地文件、多模态临时读图、或调用 Knowledge RAG。
- 旧 OCR sidecar 仍可兼容打开,但不会重新成为主路径。
+64 -1
View File
@@ -6288,6 +6288,47 @@ fn legacy_block_projection_attrs(block: &Value, block_type: &str) -> Value {
attrs.insert("tiptapImage".into(), tiptap_image);
}
}
if block_type == "media" {
for (raw_key, canonical_key) in [
("name", "name"),
("fileName", "name"),
("file_name", "name"),
("sourcePath", "sourcePath"),
("source_path", "sourcePath"),
("url", "sourcePath"),
("src", "sourcePath"),
] {
if let Some(value) = props
.and_then(|map| map.get(raw_key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
attrs.insert(canonical_key.into(), json!(value));
}
}
}
if matches!(block_type, "page_reference" | "pagereference") {
for (raw_key, canonical_key) in [
("title", "title"),
("name", "title"),
("pageId", "pageId"),
("page_id", "pageId"),
("sourcePath", "sourcePath"),
("source_path", "sourcePath"),
("href", "href"),
("url", "href"),
] {
if let Some(value) = props
.and_then(|map| map.get(raw_key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
attrs.insert(canonical_key.into(), json!(value));
}
}
}
if block_type == "table" {
if let Some(tiptap_table) = props
.and_then(|map| map.get("tiptapTable"))
@@ -13359,9 +13400,31 @@ mod tests {
}
#[test]
fn local_markdown_table_legacy_projection_exposes_tiptap_table_attrs() {
fn local_markdown_media_legacy_projection_exposes_attachment_attrs() {
let legacy = json!([{
"id": "local-block-3",
"type": "media",
"props": {
"name": "Spec.pdf",
"sourcePath": ".assets/file/Spec.pdf"
},
"content": [],
"children": []
}]);
let projection =
project_legacy_content_to_block_document("local-md:docs~2FPage.md", &legacy, &json!(0))
.expect("media block should project");
let block = &projection["blocks"][0];
assert_eq!(block["type"], json!("media"));
assert_eq!(block["attrs"]["name"], json!("Spec.pdf"));
assert_eq!(block["attrs"]["sourcePath"], json!(".assets/file/Spec.pdf"));
}
#[test]
fn local_markdown_table_legacy_projection_exposes_tiptap_table_attrs() {
let legacy = json!([{
"id": "local-block-4",
"type": "table",
"props": {
"tiptapTable": {
@@ -0,0 +1,37 @@
-- 010-knowledge-bases.sql
-- User/workspace-scoped knowledge base metadata.
--
-- Source indexing details remain in the local workspace source registry because
-- they map local files to provider document/chunk identifiers. The knowledge
-- base entity itself lives in control-plane so ownership, membership and share
-- grants can resolve it consistently across users.
CREATE TABLE IF NOT EXISTS knowledge_bases (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL DEFAULT '',
root_uri TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT,
provider TEXT NOT NULL,
provider_kb_id TEXT NOT NULL,
default_tool_enabled INTEGER NOT NULL DEFAULT 1,
can_write INTEGER NOT NULL DEFAULT 1,
source_count INTEGER NOT NULL DEFAULT 0,
chunk_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(user_id, workspace_id, provider, provider_kb_id)
);
CREATE INDEX IF NOT EXISTS idx_knowledge_bases_user_workspace
ON knowledge_bases(user_id, workspace_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_bases_root
ON knowledge_bases(root_uri, status);
CREATE INDEX IF NOT EXISTS idx_knowledge_bases_provider
ON knowledge_bases(provider, provider_kb_id);
@@ -450,6 +450,43 @@ impl ControlPlaneStore for StoreHandle {
}
}
fn upsert_knowledge_base(
&self,
input: control_plane::UpsertKnowledgeBaseInput,
) -> Result<control_plane::KnowledgeBaseRecord, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.upsert_knowledge_base(input),
StoreHandle::Turso(store) => store.upsert_knowledge_base(input),
}
}
fn list_knowledge_bases(
&self,
user_id: &str,
workspace_id: Option<&str>,
root_uri: Option<&str>,
) -> Result<Vec<control_plane::KnowledgeBaseRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => {
store.list_knowledge_bases(user_id, workspace_id, root_uri)
}
StoreHandle::Turso(store) => {
store.list_knowledge_bases(user_id, workspace_id, root_uri)
}
}
}
fn delete_knowledge_base(
&self,
user_id: &str,
knowledge_base_id: &str,
) -> Result<(), ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.delete_knowledge_base(user_id, knowledge_base_id),
StoreHandle::Turso(store) => store.delete_knowledge_base(user_id, knowledge_base_id),
}
}
fn upsert_ai_policy(
&self,
input: control_plane::UpsertAiPolicyInput,
@@ -43,6 +43,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
"v9-ai-tool-events-file-patches",
include_str!("../migrations/009-ai-tool-events-file-patches.sql"),
),
(
"v10-knowledge-bases",
include_str!("../migrations/010-knowledge-bases.sql"),
),
];
/// Create the `_migrations` meta-table if it does not exist.
+39
View File
@@ -323,6 +323,45 @@ pub struct UpsertNavigationRecentInput {
pub metadata_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KnowledgeBaseRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub root_uri: Option<String>,
pub name: String,
pub description: Option<String>,
pub provider: String,
pub provider_kb_id: String,
pub default_tool_enabled: bool,
pub can_write: bool,
pub source_count: i64,
pub chunk_count: i64,
pub status: String,
pub metadata_json: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub revision: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpsertKnowledgeBaseInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub root_uri: Option<String>,
pub name: String,
pub description: Option<String>,
pub provider: String,
pub provider_kb_id: String,
pub default_tool_enabled: bool,
pub can_write: bool,
pub source_count: i64,
pub chunk_count: i64,
pub status: String,
pub metadata_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiPolicyRecord {
pub id: EntityId,
+271 -8
View File
@@ -14,12 +14,13 @@ use crate::model::{
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, AppendAuditInput,
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
DirectoryGrantLookup, DirectoryGrantRecord, KnowledgeBaseRecord, NavigationRecentRecord,
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
SidebarShortcutRecord, SyncStateRecord, UpsertAiExternalConversationBindingInput,
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertKnowledgeBaseInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -316,6 +317,28 @@ fn row_to_navigation_recent(row: &rusqlite::Row<'_>) -> rusqlite::Result<Navigat
})
}
fn row_to_knowledge_base(row: &rusqlite::Row<'_>) -> rusqlite::Result<KnowledgeBaseRecord> {
Ok(KnowledgeBaseRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: empty_string_to_option(row.get(2)?),
root_uri: empty_string_to_option(row.get(3)?),
name: row.get(4)?,
description: row.get(5)?,
provider: row.get(6)?,
provider_kb_id: row.get(7)?,
default_tool_enabled: row.get::<_, i64>(8)? != 0,
can_write: row.get::<_, i64>(9)? != 0,
source_count: row.get(10)?,
chunk_count: row.get(11)?,
status: row.get(12)?,
metadata_json: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
revision: row.get(16)?,
})
}
fn navigation_recent_target_key(
kind: &str,
root_uri: &str,
@@ -1815,6 +1838,146 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows)
}
fn upsert_knowledge_base(
&self,
input: UpsertKnowledgeBaseInput,
) -> Result<KnowledgeBaseRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = option_to_stored_text(input.workspace_id);
let root_uri = option_to_stored_text(input.root_uri);
let name = input.name.trim().to_string();
let description = input
.description
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let provider = input.provider.trim().to_string();
let provider_kb_id = input.provider_kb_id.trim().to_string();
let status = input.status.trim().to_string();
if user_id.is_empty() || name.is_empty() || provider.is_empty() || provider_kb_id.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"knowledge base user/name/provider/provider_kb_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO knowledge_bases (
id, user_id, workspace_id, root_uri, name, description, provider,
provider_kb_id, default_tool_enabled, can_write, source_count,
chunk_count, status, metadata_json, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, 1)
ON CONFLICT(user_id, workspace_id, provider, provider_kb_id)
DO UPDATE SET
root_uri = excluded.root_uri,
name = excluded.name,
description = excluded.description,
default_tool_enabled = excluded.default_tool_enabled,
can_write = excluded.can_write,
source_count = excluded.source_count,
chunk_count = excluded.chunk_count,
status = excluded.status,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at,
revision = knowledge_bases.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("kb")),
user_id,
workspace_id,
root_uri,
name,
description,
provider,
provider_kb_id,
if input.default_tool_enabled { 1 } else { 0 },
if input.can_write { 1 } else { 0 },
input.source_count,
input.chunk_count,
if status.is_empty() { "active" } else { &status },
input.metadata_json,
now,
now,
],
)?;
let record = conn.query_row(
"SELECT id, user_id, workspace_id, root_uri, name, description, provider,
provider_kb_id, default_tool_enabled, can_write, source_count,
chunk_count, status, metadata_json, created_at, updated_at, revision
FROM knowledge_bases
WHERE user_id = ?1
AND workspace_id = ?2
AND provider = ?3
AND provider_kb_id = ?4
LIMIT 1",
params![user_id, workspace_id, provider, provider_kb_id],
row_to_knowledge_base,
)?;
Ok(record)
}
fn list_knowledge_bases(
&self,
user_id: &str,
workspace_id: Option<&str>,
root_uri: Option<&str>,
) -> Result<Vec<KnowledgeBaseRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(Vec::new());
}
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
let root_uri = root_uri.map(str::trim).unwrap_or_default();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, name, description, provider,
provider_kb_id, default_tool_enabled, can_write, source_count,
chunk_count, status, metadata_json, created_at, updated_at, revision
FROM knowledge_bases
WHERE user_id = ?1
AND status = 'active'
AND (?2 = '' OR workspace_id = ?2)
AND (?3 = '' OR root_uri = ?3)
ORDER BY created_at ASC, name ASC",
)?;
let rows = stmt
.query_map(
params![user_id, workspace_id, root_uri],
row_to_knowledge_base,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn delete_knowledge_base(
&self,
user_id: &str,
knowledge_base_id: &str,
) -> Result<(), ControlPlaneError> {
let user_id = user_id.trim();
let knowledge_base_id = knowledge_base_id.trim();
if user_id.is_empty() || knowledge_base_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"knowledge base user_id/id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE knowledge_bases
SET status = 'deleted', updated_at = ?1, revision = revision + 1
WHERE user_id = ?2 AND id = ?3 AND status = 'active'",
params![now_text(), user_id, knowledge_base_id],
)?;
if changed == 0 {
return Err(ControlPlaneError::NotFound(format!(
"knowledge base not found: {knowledge_base_id}"
)));
}
Ok(())
}
fn ensure_ai_agent_profile_policy(
&self,
user_id: &str,
@@ -3218,8 +3381,8 @@ mod tests {
use crate::model::{
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserUiPreferenceInput,
UpsertAiRuntimeRunInput, UpsertKnowledgeBaseInput, UpsertNavigationRecentInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput,
};
fn store() -> SqliteControlPlaneStore {
@@ -4035,6 +4198,106 @@ mod tests {
assert!(bob_recent.is_empty());
}
#[test]
fn knowledge_bases_are_user_scoped_upserted_and_soft_deleted() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
let first = store
.upsert_knowledge_base(UpsertKnowledgeBaseInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local-ws:alice:main".to_string()),
root_uri: Some("file:///tmp/mnote-alice".to_string()),
name: "研究资料".to_string(),
description: Some("paper set".to_string()),
provider: "lightrag_legacy".to_string(),
provider_kb_id: "lightrag-kb-a".to_string(),
default_tool_enabled: true,
can_write: true,
source_count: 0,
chunk_count: 0,
status: "active".to_string(),
metadata_json: "{}".to_string(),
})
.expect("insert knowledge base");
let updated = store
.upsert_knowledge_base(UpsertKnowledgeBaseInput {
id: None,
user_id: "alice".to_string(),
workspace_id: Some("local-ws:alice:main".to_string()),
root_uri: Some("file:///tmp/mnote-alice".to_string()),
name: "研究资料 v2".to_string(),
description: None,
provider: "lightrag_legacy".to_string(),
provider_kb_id: "lightrag-kb-a".to_string(),
default_tool_enabled: false,
can_write: true,
source_count: 3,
chunk_count: 12,
status: "active".to_string(),
metadata_json: "{\"from\":\"test\"}".to_string(),
})
.expect("update knowledge base");
assert_eq!(updated.id, first.id);
assert_eq!(updated.revision, first.revision + 1);
assert_eq!(updated.name, "研究资料 v2");
assert_eq!(updated.source_count, 3);
assert!(!updated.default_tool_enabled);
store
.upsert_knowledge_base(UpsertKnowledgeBaseInput {
id: None,
user_id: "bob".to_string(),
workspace_id: Some("local-ws:alice:main".to_string()),
root_uri: Some("file:///tmp/mnote-alice".to_string()),
name: "Bob KB".to_string(),
description: None,
provider: "lightrag_legacy".to_string(),
provider_kb_id: "lightrag-kb-a".to_string(),
default_tool_enabled: true,
can_write: true,
source_count: 1,
chunk_count: 0,
status: "active".to_string(),
metadata_json: "{}".to_string(),
})
.expect("insert bob knowledge base");
let alice_bases = store
.list_knowledge_bases(
"alice",
Some("local-ws:alice:main"),
Some("file:///tmp/mnote-alice"),
)
.expect("list alice knowledge bases");
assert_eq!(alice_bases.len(), 1);
assert_eq!(alice_bases[0].id, first.id);
store
.delete_knowledge_base("alice", &first.id)
.expect("soft delete alice knowledge base");
let alice_after_delete = store
.list_knowledge_bases(
"alice",
Some("local-ws:alice:main"),
Some("file:///tmp/mnote-alice"),
)
.expect("list alice after delete");
assert!(alice_after_delete.is_empty());
let bob_bases = store
.list_knowledge_bases(
"bob",
Some("local-ws:alice:main"),
Some("file:///tmp/mnote-alice"),
)
.expect("list bob knowledge bases");
assert_eq!(bob_bases.len(), 1);
assert_eq!(bob_bases[0].name, "Bob KB");
}
#[test]
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
let store = store();
+25 -6
View File
@@ -7,12 +7,13 @@ use crate::model::{
AiToolEventRecord, AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput,
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
SidebarShortcutRecord, SyncStateRecord, UpsertAiExternalConversationBindingInput,
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, KnowledgeBaseRecord,
NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess,
ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertKnowledgeBaseInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
};
pub trait ControlPlaneStore: Send + Sync {
@@ -187,6 +188,24 @@ pub trait ControlPlaneStore: Send + Sync {
limit: usize,
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError>;
fn upsert_knowledge_base(
&self,
input: UpsertKnowledgeBaseInput,
) -> Result<KnowledgeBaseRecord, ControlPlaneError>;
fn list_knowledge_bases(
&self,
user_id: &str,
workspace_id: Option<&str>,
root_uri: Option<&str>,
) -> Result<Vec<KnowledgeBaseRecord>, ControlPlaneError>;
fn delete_knowledge_base(
&self,
user_id: &str,
knowledge_base_id: &str,
) -> Result<(), ControlPlaneError>;
fn upsert_ai_policy(
&self,
input: UpsertAiPolicyInput,
+173 -6
View File
@@ -20,12 +20,13 @@ use crate::model::{
AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, AppendAuditInput,
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
DirectoryGrantLookup, DirectoryGrantRecord, KnowledgeBaseRecord, NavigationRecentRecord,
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
SidebarShortcutRecord, SyncStateRecord, UpsertAiExternalConversationBindingInput,
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertKnowledgeBaseInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord,
WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -850,6 +851,28 @@ fn row_to_navigation_recent(row: &libsql::Row) -> libsql::Result<NavigationRecen
})
}
fn row_to_knowledge_base(row: &libsql::Row) -> libsql::Result<KnowledgeBaseRecord> {
Ok(KnowledgeBaseRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: empty_string_to_option(row.get(2)?),
root_uri: empty_string_to_option(row.get(3)?),
name: row.get(4)?,
description: row.get(5)?,
provider: row.get(6)?,
provider_kb_id: row.get(7)?,
default_tool_enabled: row.get::<i64>(8)? != 0,
can_write: row.get::<i64>(9)? != 0,
source_count: row.get(10)?,
chunk_count: row.get(11)?,
status: row.get(12)?,
metadata_json: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
revision: row.get(16)?,
})
}
fn navigation_recent_target_key(
kind: &str,
root_uri: &str,
@@ -2339,6 +2362,150 @@ impl ControlPlaneStore for TursoControlPlaneStore {
Ok(rows)
}
fn upsert_knowledge_base(
&self,
input: UpsertKnowledgeBaseInput,
) -> Result<KnowledgeBaseRecord, ControlPlaneError> {
let user_id = input.user_id.trim().to_string();
let workspace_id = option_to_stored_text(input.workspace_id);
let root_uri = option_to_stored_text(input.root_uri);
let name = input.name.trim().to_string();
let description = input
.description
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let provider = input.provider.trim().to_string();
let provider_kb_id = input.provider_kb_id.trim().to_string();
let status = input.status.trim().to_string();
if user_id.is_empty() || name.is_empty() || provider.is_empty() || provider_kb_id.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"knowledge base user/name/provider/provider_kb_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
let conn = self.lock_conn()?;
let now = now_text();
conn.execute(
"INSERT INTO knowledge_bases (
id, user_id, workspace_id, root_uri, name, description, provider,
provider_kb_id, default_tool_enabled, can_write, source_count,
chunk_count, status, metadata_json, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, 1)
ON CONFLICT(user_id, workspace_id, provider, provider_kb_id)
DO UPDATE SET
root_uri = excluded.root_uri,
name = excluded.name,
description = excluded.description,
default_tool_enabled = excluded.default_tool_enabled,
can_write = excluded.can_write,
source_count = excluded.source_count,
chunk_count = excluded.chunk_count,
status = excluded.status,
metadata_json = excluded.metadata_json,
updated_at = excluded.updated_at,
revision = knowledge_bases.revision + 1",
params![
input.id.unwrap_or_else(|| new_id("kb")),
user_id,
workspace_id,
root_uri,
name,
description,
provider,
provider_kb_id,
if input.default_tool_enabled {
1i64
} else {
0i64
},
if input.can_write { 1i64 } else { 0i64 },
input.source_count,
input.chunk_count,
if status.is_empty() { "active" } else { &status },
input.metadata_json,
now,
now,
],
)?;
let record = conn.query_row(
"SELECT id, user_id, workspace_id, root_uri, name, description, provider,
provider_kb_id, default_tool_enabled, can_write, source_count,
chunk_count, status, metadata_json, created_at, updated_at, revision
FROM knowledge_bases
WHERE user_id = ?1
AND workspace_id = ?2
AND provider = ?3
AND provider_kb_id = ?4
LIMIT 1",
params![user_id, workspace_id, provider, provider_kb_id],
row_to_knowledge_base,
)?;
Ok(record)
}
fn list_knowledge_bases(
&self,
user_id: &str,
workspace_id: Option<&str>,
root_uri: Option<&str>,
) -> Result<Vec<KnowledgeBaseRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(Vec::new());
}
let workspace_id = workspace_id.map(str::trim).unwrap_or_default();
let root_uri = root_uri.map(str::trim).unwrap_or_default();
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, root_uri, name, description, provider,
provider_kb_id, default_tool_enabled, can_write, source_count,
chunk_count, status, metadata_json, created_at, updated_at, revision
FROM knowledge_bases
WHERE user_id = ?1
AND status = 'active'
AND (?2 = '' OR workspace_id = ?2)
AND (?3 = '' OR root_uri = ?3)
ORDER BY created_at ASC, name ASC",
)?;
let rows = stmt
.query_map(
params![user_id, workspace_id, root_uri],
row_to_knowledge_base,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn delete_knowledge_base(
&self,
user_id: &str,
knowledge_base_id: &str,
) -> Result<(), ControlPlaneError> {
let user_id = user_id.trim();
let knowledge_base_id = knowledge_base_id.trim();
if user_id.is_empty() || knowledge_base_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"knowledge base user_id/id 不能为空".to_string(),
));
}
let conn = self.lock_conn()?;
let changed = conn.execute(
"UPDATE knowledge_bases
SET status = 'deleted', updated_at = ?1, revision = revision + 1
WHERE user_id = ?2 AND id = ?3 AND status = 'active'",
params![now_text(), user_id, knowledge_base_id],
)?;
if changed == 0 {
return Err(ControlPlaneError::NotFound(format!(
"knowledge base not found: {knowledge_base_id}"
)));
}
Ok(())
}
fn ensure_ai_agent_profile_policy(
&self,
user_id: &str,
@@ -470,18 +470,33 @@ import {
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', {
detail: { documentId, workspaceId, title }
detail: {
documentId,
workspaceId,
title,
sourceKind: bootstrap.sourceKind || '',
rootUri: bootstrap.rootUri || '',
pageBlockNavigation: runtimeDescriptor.pageBlockNavigation === true,
}
}));
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
const activeTreeTab = document.querySelector('[data-mnote-sidebar-tree-tab][aria-selected="true"]');
const activeTreeMode = activeTreeTab instanceof HTMLElement
&& activeTreeTab.getAttribute('data-mnote-sidebar-tree-tab') === 'filetree'
? 'filetree'
: 'page';
const activeTreeRows = activeTreeMode === 'filetree'
? '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'
: '#sidebar-tree-root .tree-row[data-shell-mode="page"]';
document.querySelectorAll(`${activeTreeRows}[data-active="true"], ${activeTreeRows}[data-selected="true"]`).forEach((row) => {
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'false');
row.setAttribute('data-selected', 'false');
}
});
const escapedId = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape(documentId) : String(documentId).replace(/["\\]/g, '\\$&');
document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => {
document.querySelectorAll(`${activeTreeRows}[data-node-id="${escapedId}"], ${activeTreeRows}[data-document-id="${escapedId}"], ${activeTreeRows}[data-doc-id="${escapedId}"]`).forEach((row) => {
if (!(row instanceof HTMLElement)) return;
if (row.getAttribute('data-shell-mode') === 'filetree') {
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `doc:${documentId}`));
@@ -610,7 +625,7 @@ import {
}
const bootstrap = buildBootstrapFromAggregate(aggregate, descriptor, paneRole);
syncPageAggregateScript({ pageAggregateScriptId: bootstrap.pageAggregateScriptId }, aggregate);
const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap };
const runtimeDescriptor = { paneRole, root, observability, aggregate, bootstrap, pageBlockNavigation: descriptor.pageBlockNavigation === true };
updatePaneChrome(runtimeDescriptor);
if (paneRole === 'secondary' && workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'true');
@@ -677,6 +692,70 @@ import {
pushUrlState(url);
};
const openPrimaryDocumentFromPageBlockHref = (href) => {
const raw = typeof href === 'string' ? href.trim() : '';
if (!raw) return false;
let target = null;
try {
target = new URL(raw, window.location.href);
} catch (_) {
return false;
}
if (target.origin !== window.location.origin || !target.pathname.startsWith('/documents/')) return false;
const encodedId = target.pathname.slice('/documents/'.length).split('/')[0] || '';
const documentId = decodeURIComponent(encodedId).trim();
if (!documentId || typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument !== 'function') return false;
const current = currentUrl();
const workspaceId = target.searchParams.get('workspaceId') || current.searchParams.get('workspaceId') || '';
const sourceKind = target.searchParams.get('sourceKind') || current.searchParams.get('sourceKind') || '';
const rootUri = target.searchParams.get('rootUri') || current.searchParams.get('rootUri') || '';
void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({
documentId,
workspaceId,
sourceKind,
rootUri,
url: target,
pageBlockNavigation: true,
}).catch((error) => {
console.warn('mnote page block pane navigation failed, fallback to full navigation', error);
window.location.assign(target.pathname + target.search + target.hash);
});
return true;
};
const installPageBlockOpenCapture = () => {
if (window.__mnotePageBlockOpenCaptureInstalled === true) return;
window.__mnotePageBlockOpenCaptureInstalled = true;
document.addEventListener('mousedown', (event) => {
if (event.button !== 0) return;
const target = event.target;
const anchor = target instanceof Element ? target.closest('a.mnote-page-block-link') : null;
if (!(anchor instanceof HTMLAnchorElement)) return;
const href = anchor.getAttribute('href') || '';
if (!href) return;
// Tiptap's Link extension handles mouse down before the click bubble. Keep
// the target aside so that it cannot trigger a native document navigation.
anchor.setAttribute('data-mnote-page-block-href', href);
anchor.removeAttribute('href');
}, true);
document.addEventListener('click', (event) => {
const target = event.target;
const anchor = target instanceof Element ? target.closest('a.mnote-page-block-link') : null;
if (!(anchor instanceof HTMLAnchorElement)) return;
const href = anchor.getAttribute('data-mnote-page-block-href') || anchor.getAttribute('href') || '';
if (!href) return;
anchor.removeAttribute('data-mnote-page-block-href');
anchor.removeAttribute('href');
if (!openPrimaryDocumentFromPageBlockHref(href)) {
anchor.setAttribute('href', href);
return;
}
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
}, true);
};
const setSecondaryEditorHostVisible = (visible) => {
const host = document.querySelector('[data-testid="mnote-secondary-editor-tab-host"]');
if (host instanceof HTMLElement) host.hidden = !visible;
@@ -973,10 +1052,11 @@ import {
};
window.__mnoteDocumentPaneRuntime = {
openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
openPrimaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url, pageBlockNavigation } = {}) => {
const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false;
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
descriptor.pageBlockNavigation = pageBlockNavigation === true;
await replacePaneDocument('primary', descriptor);
activateMainEditorTab('', 'primary');
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
@@ -1037,6 +1117,7 @@ import {
return 1;
},
};
installPageBlockOpenCapture();
bindMainEditorPageTab('primary');
bindMainEditorPageTab('secondary');
@@ -138,6 +138,39 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|| localMarkdownRelativePathFromDocumentId(session?.documentId)
);
const localFolderNavigationFallbackUrl = (session, reason) => {
const url = new URL('/', window.location.origin);
url.searchParams.set('sourceKind', 'local_folder');
if (session?.rootUri) url.searchParams.set('rootUri', session.rootUri);
url.searchParams.set('treeView', 'filetree');
const relativePath = sessionRelativePath(session);
const currentScope = String(new URL(window.location.href).searchParams.get('fileTreeScope') || '').trim();
const parentScope = relativePath.includes('/') ? relativePath.split('/').slice(0, -1).join('/') : '';
const scope = currentScope || parentScope;
if (scope) url.searchParams.set('fileTreeScope', scope);
if (session?.documentId) url.searchParams.set('missingPage', session.documentId);
if (reason) url.searchParams.set('routeGuard', reason);
return url;
};
const navigateLocalMarkdownDeletedFallback = (session, reason = 'local_markdown_deleted') => {
if (!session || session.sessionKind === 'resource') return false;
if (session.saveTimer) {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
if (session.externalRefreshTimer) {
window.clearTimeout(session.externalRefreshTimer);
session.externalRefreshTimer = 0;
}
session.externalChangePending = false;
session.hasExternalConflict = false;
session.bufferDirtyState = 'Deleted';
clearSessionConflictSurface(session);
window.location.assign(localFolderNavigationFallbackUrl(session, reason).toString());
return true;
};
const localFolderEventItems = (payload) => {
if (!payload || typeof payload !== 'object') return [];
const changedPaths = Array.isArray(payload.changedPaths)
@@ -151,6 +184,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(),
documentId: String(item.documentId || item.document_id || '').trim(),
eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(),
changeType: String(item.changeType || item.change_type || '').trim(),
selfWriteEcho: Boolean(item.selfWriteEcho || item.self_write_echo),
fileVersion: String(item.fileVersion || item.file_version || item.observedFileVersion || item.observed_file_version || '').trim(),
observedFileVersion: String(item.observedFileVersion || item.observed_file_version || '').trim(),
@@ -1167,19 +1201,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const bufferState = await fetchSessionBufferState(session);
const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim();
if (dirtyState === 'Deleted') {
session.lastExternalConflictEnvelope = {
code: 'local_markdown_deleted',
documentId: session.documentId,
rootUri: session.rootUri || null,
currentDiskVersion: bufferState?.fileVersion || session.conflictDetectionKey || session.fileVersion || null,
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
externalActor: bufferState?.externalActor || null,
dirtyState,
bufferFileVersion: bufferState?.fileVersion || session.fileVersion || null,
message: '当前本地 Markdown 文件已被删除,请恢复文件或关闭当前标签',
suggestedActions: ['keep_editor', 'open_diff'],
};
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
navigateLocalMarkdownDeletedFallback(session);
return;
}
if (session.hasExternalConflict) {
@@ -1209,13 +1231,24 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const errorCode = String(payload?.error?.code || payload?.code || '').trim();
if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found')) {
navigateLocalMarkdownDeletedFallback(session, 'local_markdown_not_found');
return;
}
const conflictEnvelope = conflictEnvelopeFromResponse(payload);
if (response.status === 409 && conflictEnvelope) {
session.lastExternalConflictEnvelope = conflictEnvelope;
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
if (session.sourceKind === 'local_folder') {
markSessionExternalConflict(session, externalConflictMessage);
}
return;
}
const payload = await response.json();
const conflictEnvelope = conflictEnvelopeFromResponse(payload);
if (response.status === 409 && conflictEnvelope) {
session.lastExternalConflictEnvelope = conflictEnvelope;
@@ -1404,6 +1437,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|| (!documentId && relativePath && relativeTarget && relativePath === relativeTarget)
);
if (!targetsCurrentDocument) return;
const changeType = String(item.changeType || '').trim();
if (changeType === 'deleted') {
navigateLocalMarkdownDeletedFallback(targetSession);
return;
}
if (item.selfWriteEcho === true) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
@@ -197,9 +197,67 @@ export const legacyBlockToTiptap = (block, index = 0) => {
}),
};
}
if (type === 'page_reference' || type === 'pageReference' || type === 'pagereference') {
const sourcePath = firstNonEmptyText(
block?.props?.sourcePath,
block?.props?.source_path,
block?.props?.href,
block?.props?.url,
blockAttrs?.sourcePath,
blockAttrs?.source_path,
blockAttrs?.href,
blockAttrs?.url,
blockAttrs?.pageId,
blockAttrs?.page_id
);
const title = firstNonEmptyText(
block?.props?.title,
block?.props?.name,
blockAttrs?.title,
blockAttrs?.name,
content,
sourcePath
) || '页面';
return {
type: 'paragraph',
attrs: withTextAlign({ blockId }),
content: [{
type: 'text',
text: title,
marks: [{
type: 'link',
attrs: {
href: sourcePath || '#',
target: '_self',
rel: 'noopener noreferrer nofollow',
class: 'mnote-page-block-link',
},
}],
}],
};
}
if (type === 'media') {
const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src);
const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath);
const sourcePath = firstNonEmptyText(
block?.props?.sourcePath,
block?.props?.source_path,
block?.props?.url,
block?.props?.src,
blockAttrs?.sourcePath,
blockAttrs?.source_path,
blockAttrs?.url,
blockAttrs?.src
);
const name = firstNonEmptyText(
block?.props?.name,
block?.props?.fileName,
block?.props?.file_name,
block?.props?.title,
blockAttrs?.name,
blockAttrs?.fileName,
blockAttrs?.file_name,
blockAttrs?.title,
sourcePath
);
const mediaContent = name
? [{
type: 'text',
@@ -336,7 +394,7 @@ export const localMarkdownDocumentIdFromRelativePath = (relativePath) => {
if (!normalized) return '';
return 'local-md:' + normalized
.split('/')
.map((part) => part.replace(/ /g, '~20'))
.map((part) => encodeURIComponent(part).replace(/%/g, '~'))
.join('~2F');
};
@@ -377,6 +435,48 @@ export const normalizeLocalAssetRelativePath = (value, context) => {
.join('/');
};
export const normalizeLocalPageRelativePath = (value, context) => {
const text = unwrapMarkdownLinkTarget(value);
if (!text || text.startsWith('#')) return '';
if (text.startsWith('/documents/')) return text;
if (text.startsWith('/api/') || text.startsWith('http://') || text.startsWith('https://') || text.startsWith('mailto:')) return '';
const baseDir = text.startsWith('/') ? '' : localMarkdownDirectoryFromDocumentId(context?.documentId);
const parts = (baseDir ? `${baseDir}/${text}` : text)
.replace(/\\/g, '/')
.split('/');
const normalized = [];
for (const part of parts) {
if (!part || part === '.') continue;
if (part === '..') {
if (!normalized.length) return '';
normalized.pop();
continue;
}
normalized.push(part);
}
return normalized.join('/');
};
export const localPageOpenUrlForTiptap = (value, context) => {
const text = unwrapMarkdownLinkTarget(value);
if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return text || value;
if (text.startsWith('/documents/')) {
const url = new URL(text, window.location.origin);
if (!url.searchParams.get('sourceKind')) url.searchParams.set('sourceKind', 'local_folder');
if (!url.searchParams.get('rootUri')) url.searchParams.set('rootUri', context.rootUri);
return `${url.pathname}${url.search}`;
}
const relativePath = normalizeLocalPageRelativePath(text, context);
if (!relativePath) return text || value;
const documentId = localMarkdownDocumentIdFromRelativePath(relativePath);
if (!documentId) return text || value;
const url = new URL(`/documents/${documentId}`, window.location.origin);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', context.rootUri);
url.searchParams.set('treeView', 'filetree');
return `${url.pathname}${url.search}`;
};
export const localFileOpenUrlForTiptap = (value, context) => {
if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value;
const relativePath = normalizeLocalAssetRelativePath(value, context);
@@ -463,6 +563,8 @@ const attachmentRefForTiptapHref = (href, context) => {
const withAttachmentProjectionAttrs = (attrs, attachmentRef) => {
if (!attachmentRef || typeof attachmentRef !== 'object') return attrs;
const next = { ...attrs };
const fileSize = attachmentFileSizeText(attachmentRef);
if (fileSize) next['data-file-size'] = fileSize;
if (attachmentRef.exists === false) {
next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-missing');
next['data-mnote-attachment-missing'] = 'true';
@@ -476,6 +578,14 @@ const withAttachmentProjectionAttrs = (attrs, attachmentRef) => {
return next;
};
export const attachmentFileSizeText = (attachmentRef) => {
const size = Number(attachmentRef && (attachmentRef.fileSize || attachmentRef.file_size) || 0);
if (!Number.isFinite(size) || size <= 0) return '';
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2)} MB`;
if (size >= 1024) return `${(size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2)} KB`;
return `${Math.round(size)} B`;
};
export const localizeTiptapAssetUrls = (node, context) => {
if (!node || typeof node !== 'object') return node;
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
@@ -490,12 +600,26 @@ export const localizeTiptapAssetUrls = (node, context) => {
node.marks = node.marks.map((mark) => {
if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark;
const href = String(mark.attrs.href || '').trim();
const markClass = String(mark.attrs.class || '');
if (markClass.split(/\s+/).includes('mnote-page-block-link')) {
return {
...mark,
attrs: {
...mark.attrs,
href: localPageOpenUrlForTiptap(href, context),
target: mark.attrs.target || '_self',
rel: mark.attrs.rel || 'noopener noreferrer nofollow',
class: mergeClassNames(mark.attrs.class, 'mnote-page-block-link'),
},
};
}
const attachmentClass = localAttachmentClassForTiptapHref(href);
const attachmentRef = attachmentRefForTiptapHref(href, context);
const localizedHref = attachmentClass ? localFileOpenUrlForTiptap(href, context) : href;
const attrs = attachmentClass
? withAttachmentProjectionAttrs({
...mark.attrs,
href,
href: localizedHref,
class: mergeClassNames(mark.attrs.class, attachmentClass),
target: mark.attrs.target || '_blank',
rel: mark.attrs.rel || 'noopener noreferrer nofollow',
@@ -92,6 +92,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
function localMarkdownAttachmentPathFromHref(href, documentId) {
var raw = String(href || '').trim();
if (!raw || raw.indexOf('#') === 0 || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return '';
if (raw.indexOf('/documents/') === 0 || raw.indexOf('/api/') === 0) return '';
var documentPath = localMarkdownPathFromDocumentId(documentId);
if (!documentPath) return '';
var slashIndex = documentPath.lastIndexOf('/');
@@ -271,6 +272,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
async function refreshLocalAttachmentExistence(link) {
if (!(link instanceof HTMLAnchorElement)) return;
if (link.classList.contains('mnote-page-block-link')) return;
var href = link.getAttribute('href') || link.href || '';
var paneContext = editorAttachmentPaneContext(link);
var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : '');
@@ -317,6 +319,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
function refreshEditorLocalAttachmentExistence() {
document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
if (link instanceof HTMLAnchorElement && link.classList.contains('mnote-page-block-link')) return;
if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
});
}
@@ -418,16 +421,33 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
try {
absoluteHref = new URL(href, window.location.href).href;
} catch (_) {}
var localPath = normalizeLocalRelativePath(
localFileOpenPathFromHref(href) || localMarkdownAttachmentPathFromHref(href, paneContext && paneContext.documentId)
);
return refs.find(function(ref) {
if (!ref || typeof ref !== 'object') return false;
var refRelativePath = normalizeLocalRelativePath(ref.relativePath || '');
var refNormalizedHref = normalizeLocalRelativePath(ref.normalizedHref || ref.rawHref || '').replace(/^\.\//, '');
return String(ref.rawHref || '') === href
|| String(ref.normalizedHref || '') === href
|| String(ref.resolvedUri || '') === href
|| (absoluteHref && String(ref.resolvedUri || '') === absoluteHref)
|| (localPath && refRelativePath && refRelativePath === localPath)
|| (localPath && refNormalizedHref && localPath.endsWith('/' + refNormalizedHref))
|| (!href && textLabel && String(ref.label || '').trim() === textLabel);
}) || null;
}
function attachmentFileSizeLabel(attachmentRef) {
if (!attachmentRef || typeof attachmentRef !== 'object') return '';
if (typeof uploadedFileSize === 'function') return uploadedFileSize(attachmentRef);
var size = Number(attachmentRef.file_size || attachmentRef.fileSize || 0);
if (!Number.isFinite(size) || size <= 0) return '';
if (size >= 1024 * 1024) return (size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2) + ' MB';
if (size >= 1024) return (size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2) + ' KB';
return String(Math.round(size)) + ' B';
}
function detailFromEditorAttachmentLink(link) {
var rawHref = link instanceof HTMLAnchorElement ? (link.getAttribute('href') || '') : '';
var params = attachmentQueryParams(rawHref);
@@ -483,6 +503,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
function enhanceEditorAttachmentLink(link) {
if (!(link instanceof HTMLAnchorElement)) return;
var href = link.getAttribute('href') || '';
if (link.classList.contains('mnote-page-block-link')) return;
var params = attachmentQueryParams(href);
var paneContext = editorAttachmentPaneContext(link);
var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : '');
@@ -509,6 +530,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
if (typeof attachmentRef.authorized === 'boolean') {
setEditorAttachmentUnauthorizedState(link, attachmentRef.authorized === false);
}
var fileSizeLabel = attachmentFileSizeLabel(attachmentRef);
if (fileSizeLabel) setAttributeIfChanged(link, 'data-file-size', fileSizeLabel);
}
setAttributeIfChanged(link, 'target', '_blank');
setAttributeIfChanged(link, 'rel', 'noopener noreferrer nofollow');
@@ -24,6 +24,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
openEditorAttachmentEditTab,
openEditorAttachmentNewWindow,
openLocalResourceInActiveTab,
knowledgeHostUrl,
refreshLocalFolderSidebarSnapshot,
removeFileTreeAssetRow,
revealFileTreeResource,
@@ -467,7 +468,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return;
}
if (action === 'open-knowledge-page') {
var knowledgeUrl = '/knowledge';
var knowledgeUrl = typeof knowledgeHostUrl === 'function'
? knowledgeHostUrl({
workspaceId: String(detail && detail.workspaceId || resolveWorkspaceId(trigger || document.body) || '').trim(),
sourceKind: currentSourceKind() || 'local_folder',
rootUri: knowledgeRagRootUri(detail, trigger)
})
: '/knowledge';
if (typeof window.open === 'function') {
window.open(knowledgeUrl, '_blank', 'noopener,noreferrer');
} else {
@@ -705,7 +705,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-weknora-kb-page-title">' +
'<span class="mnote-weknora-kb-page-eyebrow">LightRAG</span>' +
'<strong>知识库</strong>' +
'<span>由 MNote source registry 与 LightRAG 索引驱动</span>' +
'<span>资料、索引状态和检索</span>' +
'</div>' +
'<div class="mnote-weknora-kb-page-actions">' +
'<button type="button" data-knowledge-rag-action="refresh" title="刷新"><span class="material-symbols-outlined" data-icon="refresh" aria-hidden="true"></span></button>' +
@@ -714,44 +714,54 @@ export function createSidebarPageSettingsRuntime(context) {
'</div>' +
'</div>' +
'<div class="mnote-weknora-kb-page-status" data-testid="mnote-knowledge-rag-status"></div>' +
'<div class="mnote-weknora-kb-page-body">' +
'<aside class="mnote-weknora-kb-list-pane" data-testid="mnote-weknora-kb-list-pane">' +
'<div class="mnote-weknora-kb-page-body" data-kb-list-hidden="true">' +
'<aside class="mnote-weknora-kb-list-pane" data-testid="mnote-weknora-kb-list-pane" hidden>' +
'<div class="mnote-weknora-kb-list-head">' +
'<div><strong>资料库</strong><span data-testid="mnote-weknora-kb-list-count">0</span></div>' +
'<button type="button" data-knowledge-rag-action="focus-create-kb" title="LightRAG 当前使用单资料库" hidden><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
'<button type="button" data-knowledge-rag-action="toggle-create-kb" title="新建资料库"><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
'</div>' +
'<div class="mnote-weknora-kb-card-wrap" data-testid="mnote-weknora-kb-card-list"></div>' +
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls" hidden>' +
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建逻辑资料库</strong></div>' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="create-kb">创建知识库</button>' +
'<span class="mnote-knowledge-rag-input-status" data-testid="mnote-weknora-create-kb-status" data-knowledge-rag-create-kb-status hidden></span>' +
'</section>' +
'</aside>' +
'<section class="mnote-weknora-kb-detail-pane" data-testid="mnote-weknora-kb-detail-pane">' +
'<div class="mnote-weknora-kb-detail-hero" data-testid="mnote-weknora-kb-detail-hero">' +
'<div>' +
'<nav class="mnote-weknora-kb-breadcrumb" data-testid="mnote-weknora-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-weknora-kb-breadcrumb-current">LightRAG 资料库</strong></nav>' +
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>Source Registry</span>' +
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>资料来源</span>' +
'<h2 data-testid="mnote-weknora-kb-detail-title">LightRAG 资料库</h2>' +
'<p data-testid="mnote-weknora-kb-detail-description">MNote 管理本地来源、引用定位和权限边界,LightRAG 负责索引与召回。</p>' +
'</div>' +
'<select class="mnote-kb-params-select" data-testid="mnote-weknora-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select>' +
'</div>' +
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="LightRAG 知识库详情">' +
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">来源</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-weknora-tab-wiki">Wiki</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-weknora-tab-graph">Graph</button>' +
'<section class="mnote-knowledge-rag-kb-manager" data-testid="mnote-knowledge-rag-kb-manager">' +
'<div class="mnote-knowledge-rag-kb-manager-row">' +
'<label class="mnote-knowledge-rag-kb-select-field"><span>当前资料库</span><select class="mnote-kb-params-select" data-testid="mnote-weknora-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select></label>' +
'<button type="button" data-knowledge-rag-action="toggle-create-kb"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span>新建</button>' +
'<button type="button" data-knowledge-rag-action="delete-kb" data-knowledge-rag-delete-selected-kb="true"><span class="material-symbols-outlined" data-icon="delete" aria-hidden="true"></span>删除</button>' +
'</div>' +
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls" hidden>' +
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建资料库</strong></div>' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="create-kb">创建知识库</button>' +
'<span class="mnote-knowledge-rag-input-status" data-testid="mnote-weknora-create-kb-status" data-knowledge-rag-create-kb-status hidden></span>' +
'</section>' +
'<div class="mnote-knowledge-rag-kb-manager-note" data-testid="mnote-knowledge-rag-kb-manager-note"></div>' +
'</section>' +
'<details class="mnote-knowledge-rag-diagnostics" data-testid="mnote-knowledge-rag-diagnostics">' +
'<summary>诊断信息</summary>' +
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
'</details>' +
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="当前资料库内容">' +
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">资料</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-weknora-tab-wiki" hidden>Wiki</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-weknora-tab-graph" hidden>Graph</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">检索</button>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="sources">' +
'<div class="mnote-weknora-doc-layout" data-testid="mnote-weknora-document-management">' +
'<aside class="mnote-weknora-doc-sidebar" data-testid="mnote-weknora-doc-source-sidebar">' +
'<aside class="mnote-weknora-doc-sidebar" data-testid="mnote-weknora-doc-source-sidebar" hidden>' +
'<div class="mnote-weknora-doc-sidebar-head"><strong>来源</strong><span>MNote registry</span></div>' +
'<div class="mnote-weknora-doc-source-tags" data-testid="mnote-weknora-doc-source-tags">' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="all" data-active="true"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>全部来源</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="all" data-active="true"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>全部资料</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexed"><span class="material-symbols-outlined" data-icon="check_circle" aria-hidden="true"></span>已索引</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>处理中</button>' +
'</div>' +
@@ -760,21 +770,23 @@ export function createSidebarPageSettingsRuntime(context) {
'</aside>' +
'<section class="mnote-weknora-doc-main">' +
'<div class="mnote-weknora-doc-toolbar">' +
'<div class="mnote-weknora-doc-search"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span><input type="text" data-testid="mnote-weknora-doc-filter-input" data-knowledge-rag-doc-filter-input="true" placeholder="搜索或筛选当前知识库文档" spellcheck="false" /></div>' +
'<div class="mnote-weknora-doc-search"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span><input type="text" data-testid="mnote-weknora-doc-filter-input" data-knowledge-rag-doc-filter-input="true" placeholder="搜索或筛选当前资料库内容" spellcheck="false" /></div>' +
'<div class="mnote-weknora-view-switch" role="group" aria-label="文档视图切换" data-testid="mnote-weknora-view-switch">' +
'<button type="button" class="is-active" data-knowledge-rag-view="list" aria-pressed="true" title="列表视图"><span class="material-symbols-outlined" data-icon="view_list" aria-hidden="true"></span></button>' +
'<button type="button" data-knowledge-rag-view="grid" aria-pressed="false" title="网格视图"><span class="material-symbols-outlined" data-icon="grid_view" aria-hidden="true"></span></button>' +
'</div>' +
'<button type="button" data-knowledge-rag-action="use-filetree-selection"><span class="material-symbols-outlined" data-icon="account_tree" aria-hidden="true"></span>添加文件夹</button>' +
'<button type="button" data-knowledge-rag-action="add-source"><span class="material-symbols-outlined" data-icon="upload_file" aria-hidden="true"></span>添加文档</button>' +
'<button type="button" data-knowledge-rag-action="use-filetree-selection"><span class="material-symbols-outlined" data-icon="account_tree" aria-hidden="true"></span>从文件树添加</button>' +
'<button type="button" class="mnote-knowledge-rag-primary-action" data-knowledge-rag-action="add-source"><span class="material-symbols-outlined" data-icon="upload_file" aria-hidden="true"></span>添加资料</button>' +
'<button type="button" data-knowledge-rag-action="ingest-active"><span class="material-symbols-outlined" data-icon="note_add" aria-hidden="true"></span>加入当前页</button>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
knowledgeRagSourceInputRow('') +
'</div>' +
'<div class="mnote-weknora-doc-actions">' +
'<button type="button" data-knowledge-rag-action="ingest">索引到 LightRAG</button>' +
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
'<div class="mnote-knowledge-rag-source-composer" data-knowledge-rag-source-composer="true" hidden>' +
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
knowledgeRagSourceInputRow('') +
'</div>' +
'<div class="mnote-weknora-doc-actions">' +
'<button type="button" class="mnote-knowledge-rag-primary-action" data-knowledge-rag-action="ingest">开始索引</button>' +
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' +
'<div class="mnote-weknora-doc-table" data-testid="mnote-knowledge-rag-sources" data-weknora-document-list-view="true"></div>' +
@@ -795,11 +807,11 @@ export function createSidebarPageSettingsRuntime(context) {
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="search" hidden>' +
'<div class="mnote-kb-search-form">' +
'<input type="text" class="mnote-kb-search-input" data-kb-rag-search-input="true" placeholder="输入检索关键词...">' +
'<button type="button" class="wolai-page-settings-index-add" data-kb-rag-action="search">索</button>' +
'<div class="mnote-kb-search-box"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span><input type="text" class="mnote-kb-search-input" data-kb-rag-search-input="true" placeholder="在当前资料库中检索关键词" spellcheck="false"></div>' +
'<button type="button" class="wolai-page-settings-index-add" data-kb-rag-action="search"><span class="material-symbols-outlined" data-icon="search" aria-hidden="true"></span>检索</button>' +
'</div>' +
'<div class="mnote-kb-search-results" data-kb-rag-search-results="true">' +
'<span class="mnote-extensions-loading">输入关键词后用 LightRAG hybrid 检索</span>' +
'<div class="mnote-kb-search-empty">输入关键词后检索当前资料库</div>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="params" hidden>' +
@@ -819,7 +831,7 @@ export function createSidebarPageSettingsRuntime(context) {
'</div>' +
'<div class="mnote-kb-params-row">' +
'<label class="mnote-kb-params-label">排序说明</label>' +
'<span class="mnote-kb-params-help">结果按 provider 排序分返回;兼容阈值字段保留在请求层,不作为默认主控。</span>' +
'<span class="mnote-kb-params-help">结果按相关度排序;高级阈值保留在请求层。</span>' +
'</div>' +
'<button type="button" class="wolai-page-settings-index-add" data-kb-rag-action="save-params">保存参数</button>' +
'</div>' +
@@ -1194,6 +1206,12 @@ export function createSidebarPageSettingsRuntime(context) {
'</div>';
}
function setKnowledgeRagSourceComposerVisible(visible) {
var popover = ensureKnowledgeRagSettingsPopover();
var composer = popover.querySelector('[data-knowledge-rag-source-composer]');
if (composer instanceof HTMLElement) composer.hidden = !visible;
}
function knowledgeRagSourceInputValues() {
var popover = ensureKnowledgeRagSettingsPopover();
return Array.from(popover.querySelectorAll('[data-knowledge-rag-source-input]')).map(function(input) {
@@ -1342,6 +1360,25 @@ export function createSidebarPageSettingsRuntime(context) {
return knowledgeRagStatusValue(status);
}
function knowledgeRagUserStatusLabel(status) {
var text = String(status || '').trim().toLowerCase();
if (!text) return '';
if (text === 'provider_missing') return '索引记录缺失';
if (text === 'failed' || text.includes('fail')) return '索引失败';
if (text === 'delete_submitted') return '正在移除索引';
if (text === 'delete_completed') return '已移除索引';
if (text === 'provider_mapped' || text === 'completed' || text === 'processed' || text === 'indexed') return '已索引';
if (['pending', 'submitted', 'scanning', 'parsing', 'parse', 'parsed', 'analyzing', 'analyze', 'processing', 'process'].includes(text)) return '处理中';
return text.replace(/_/g, ' ');
}
function knowledgeRagUserDescription(description) {
var text = String(description || '').trim();
if (!text) return '';
if (/source registry|provider|sidecar|chunk|kb id|knowledge base id/i.test(text)) return '';
return text;
}
function knowledgeRagProviderKnowledgeBaseIds(status) {
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
var weknora = config.weknora && typeof config.weknora === 'object' ? config.weknora : {};
@@ -1415,11 +1452,12 @@ export function createSidebarPageSettingsRuntime(context) {
status: 'configured'
});
});
if (!output.length && ['lightrag', 'lightrag_legacy'].includes(knowledgeRagProviderName(status || {}))) {
output.push({
if (!seen['lightrag-default'] && ['lightrag', 'lightrag_legacy'].includes(knowledgeRagProviderName(status || {}))) {
seen['lightrag-default'] = true;
output.unshift({
providerKbId: 'lightrag-default',
name: '默认资料库',
description: 'LightRAG 单工作区索引,由 MNote source registry 划分范围',
description: '包含当前工作区的全部已索引资料',
sourceCount: ((status && status.registry && Array.isArray(status.registry.entries)) ? status.registry.entries.length : 0),
chunkCount: 0,
updatedAt: '',
@@ -1440,6 +1478,7 @@ export function createSidebarPageSettingsRuntime(context) {
function setKnowledgeRagSelectedProviderKnowledgeBaseId(providerKnowledgeBaseId) {
pageUiState.knowledgeRagSelectedProviderKnowledgeBaseId = String(providerKnowledgeBaseId || '').trim();
pageUiState.knowledgeRagSourceFilter = 'all';
renderKnowledgeRagSettings();
}
@@ -1491,22 +1530,47 @@ export function createSidebarPageSettingsRuntime(context) {
};
}
function knowledgeRagBaseStatusLabel(status) {
var text = String(status || 'active').trim().toLowerCase();
if (!text || text === 'active' || text === 'configured') return '可用';
if (text === 'processing' || text === 'indexing') return '处理中';
if (text === 'test' || text === 'preview' || text === 'local') return '测试';
if (text === 'failed' || text === 'error') return '异常';
return String(status || '可用').replace(/_/g, ' ');
}
function knowledgeRagOptimisticBases() {
return Array.isArray(pageUiState.knowledgeRagOptimisticBases) ? pageUiState.knowledgeRagOptimisticBases : [];
}
function knowledgeRagBaseIsOptimistic(providerKnowledgeBaseId) {
var selected = String(providerKnowledgeBaseId || '').trim();
return Boolean(selected && knowledgeRagOptimisticBases().some(function(base) {
return knowledgeRagBaseProviderKbId(base) === selected;
}));
}
function renderKnowledgeRagKnowledgeBaseCards(popover, status, entries, providerDocs) {
var listNode = popover.querySelector('[data-testid="mnote-weknora-kb-card-list"]');
var countNode = popover.querySelector('[data-testid="mnote-weknora-kb-list-count"]');
var listPane = popover.querySelector('[data-testid="mnote-weknora-kb-list-pane"]');
var pageBody = popover.querySelector('.mnote-weknora-kb-page-body');
if (!(listNode instanceof HTMLElement)) return;
var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
var providerName = knowledgeRagProviderName(status || {});
if (countNode instanceof HTMLElement) countNode.textContent = String(bases.length);
if (listPane instanceof HTMLElement) listPane.hidden = bases.length <= 1;
if (pageBody instanceof HTMLElement) pageBody.setAttribute('data-kb-list-hidden', bases.length <= 1 ? 'true' : 'false');
if (!bases.length) {
listNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有知识库来源</div>';
listNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有资料库</div>';
return;
}
listNode.innerHTML = bases.map(function(base) {
var stats = knowledgeRagBaseStats(base, entries, providerDocs);
var isActive = base.providerKbId === selected;
var baseStatus = String(base.status || 'active');
var baseStatusLabel = knowledgeRagBaseStatusLabel(baseStatus);
return '' +
'<button type="button" class="mnote-weknora-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
'<span class="mnote-weknora-kb-card-star"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span></span>' +
@@ -1519,12 +1583,13 @@ export function createSidebarPageSettingsRuntime(context) {
(stats.processingCount ? '<i class="is-processing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>' + escapeHtml(String(stats.processingCount)) + '</i>' : '') +
'</span>' +
'</span>' +
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatus) + '</span>' +
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatusLabel) + '</span>' +
'</button>';
}).join('');
}
function renderKnowledgeRagKnowledgeBaseHero(popover, status, entries, providerDocs) {
var heroNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-hero"]');
var titleNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-title"]');
var descNode = popover.querySelector('[data-testid="mnote-weknora-kb-detail-description"]');
var breadcrumbNode = popover.querySelector('[data-testid="mnote-weknora-kb-breadcrumb-current"]');
@@ -1532,6 +1597,7 @@ export function createSidebarPageSettingsRuntime(context) {
var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
var base = bases.find(function(item) { return item.providerKbId === selected; }) || bases[0] || null;
if (heroNode instanceof HTMLElement) heroNode.hidden = true;
if (!base) {
titleNode.textContent = '未配置知识库';
descNode.textContent = '加入资料来源后,可在这里管理 MNote 授权的本地文件和引用。';
@@ -1541,27 +1607,66 @@ export function createSidebarPageSettingsRuntime(context) {
var stats = knowledgeRagBaseStats(base, entries, providerDocs);
titleNode.textContent = base.name;
if (breadcrumbNode instanceof HTMLElement) breadcrumbNode.textContent = base.name;
descNode.textContent = (base.description || '暂无描述') + ' · 文档 ' + stats.sourceCount + ' · chunk ' + stats.chunkCount + ' · processing ' + stats.processingCount;
var userDescription = knowledgeRagUserDescription(base.description);
descNode.textContent = stats.sourceCount + ' 个资料 · ' + stats.processingCount + ' 个处理中 · ' + stats.chunkCount + ' 个片段'
+ (userDescription ? ' · ' + userDescription : '');
}
function renderKnowledgeRagKnowledgeBaseControls(popover, status) {
var select = popover.querySelector('[data-knowledge-rag-kb-select]');
if (!(select instanceof HTMLSelectElement)) return;
function renderKnowledgeRagKnowledgeBaseControls(popover, status, entries, providerDocs) {
var selects = Array.from(popover.querySelectorAll('[data-knowledge-rag-kb-select]'))
.filter(function(select) { return select instanceof HTMLSelectElement; });
var deleteButton = popover.querySelector('[data-knowledge-rag-delete-selected-kb]');
var noteNode = popover.querySelector('[data-testid="mnote-knowledge-rag-kb-manager-note"]');
var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
if (!bases.length) {
select.innerHTML = '<option value="">未配置 KB</option>';
select.value = '';
select.disabled = true;
selects.forEach(function(select) {
select.innerHTML = '<option value="">未配置资料库</option>';
select.value = '';
select.disabled = true;
select.hidden = false;
});
if (deleteButton instanceof HTMLButtonElement) {
deleteButton.disabled = true;
deleteButton.title = '没有可删除的资料库';
}
if (noteNode instanceof HTMLElement) noteNode.textContent = '新建资料库后,可在这里切换当前工作区的资料范围。';
return;
}
select.disabled = false;
select.innerHTML = bases.map(function(base) {
var label = base.name + ' · ' + base.sourceCount + ' sources · ' + base.providerKbId;
var activeBase = bases.find(function(base) { return base.providerKbId === selected; }) || bases[0];
var selectedValue = activeBase.providerKbId;
var activeStats = knowledgeRagBaseStats(activeBase, entries || [], providerDocs || []);
var optionsHtml = bases.map(function(base) {
var stats = knowledgeRagBaseStats(base, entries || [], providerDocs || []);
var label = base.name + ' · ' + stats.sourceCount + ' 个资料';
return '<option value="' + escapeHtml(base.providerKbId) + '">' + escapeHtml(label) + '</option>';
}).join('');
select.value = selected || bases[0].providerKbId;
pageUiState.knowledgeRagSelectedProviderKnowledgeBaseId = select.value;
selects.forEach(function(select) {
select.disabled = false;
select.hidden = false;
select.innerHTML = optionsHtml;
select.value = selectedValue;
});
pageUiState.knowledgeRagSelectedProviderKnowledgeBaseId = selectedValue;
if (deleteButton instanceof HTMLButtonElement) {
var canDelete = selectedValue !== 'lightrag-default' && activeStats.sourceCount === 0 && activeStats.processingCount === 0;
deleteButton.disabled = !canDelete;
deleteButton.setAttribute('data-knowledge-rag-kb-id', selectedValue);
deleteButton.title = canDelete ? '删除当前空资料库' : (selectedValue === 'lightrag-default' ? '默认资料库不能删除' : '请先移除资料后再删除资料库');
}
if (noteNode instanceof HTMLElement) {
var providerName = knowledgeRagProviderName(status || {});
var activeName = activeBase.name || '当前资料库';
if (selectedValue === 'lightrag-default') {
noteNode.textContent = '默认资料库显示当前工作区全部已索引资料;新建资料库可管理独立资料范围。';
} else if (activeStats.sourceCount === 0 && activeStats.processingCount === 0) {
noteNode.textContent = activeName + ':空资料库,可添加资料或直接删除。';
} else if (providerName === 'weknora') {
noteNode.textContent = bases.length > 1 ? '已连接多个资料库,可用下拉框切换当前范围。' : '当前只有一个资料库,可新建后切换。';
} else {
noteNode.textContent = bases.length > 1 ? '当前可切换资料库;删除前需要先移除其中资料。' : '当前使用默认资料库,也可以新建一个资料库管理独立范围。';
}
}
}
function knowledgeRagKbSummaryHtml(status, entries, providerDocs) {
@@ -1581,8 +1686,8 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-weknora-kb-summary-card" data-testid="mnote-weknora-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="' + escapeHtml(knowledgeRagProviderName(status || {})) + '" data-knowledge-rag-kb-status="configured">' +
'<div><span>知识库</span><strong>' + escapeHtml(activeCount ? String(activeCount) + ' 个' : '未配置') + '</strong></div>' +
'<div><span>文档</span><strong>' + escapeHtml(String((entries || []).length)) + '</strong></div>' +
'<div><span>Provider docs</span><strong>' + escapeHtml(String((providerDocs || []).length)) + '</strong></div>' +
'<div><span>Processing</span><strong data-knowledge-rag-processing-count="' + escapeHtml(String(processingCount)) + '">' + escapeHtml(String(processingCount)) + '</strong></div>' +
'<div><span>索引文档</span><strong>' + escapeHtml(String((providerDocs || []).length)) + '</strong></div>' +
'<div><span>处理中</span><strong data-knowledge-rag-processing-count="' + escapeHtml(String(processingCount)) + '">' + escapeHtml(String(processingCount)) + '</strong></div>' +
'</div>';
}
@@ -1797,8 +1902,8 @@ export function createSidebarPageSettingsRuntime(context) {
startKnowledgeRagPipelineWatcher(reason);
}
function knowledgeRagUnmappedProviderRows(status, entries) {
var docs = knowledgeRagProviderDocuments(status);
function knowledgeRagUnmappedProviderRows(status, entries, providerDocs) {
var docs = Array.isArray(providerDocs) ? providerDocs : knowledgeRagProviderDocuments(status);
if (!docs.length) return [];
var mappedDocIds = new Set();
var mappedFilePaths = new Set();
@@ -1846,14 +1951,14 @@ export function createSidebarPageSettingsRuntime(context) {
}, { all: 0, attention: 0, indexing: 0, failed: 0, indexed: 0, removed: 0, unmapped: 0 });
var current = knowledgeRagCurrentFilter();
var options = [
['all', '全部'],
['attention', '需处理'],
['indexing', '索引中'],
['indexing', '处理中'],
['failed', '失败/过期'],
['indexed', '已索引'],
['removed', '已移除'],
['unmapped', 'Provider 未映射'],
['all', '全部']
['indexed', '已索引']
];
if (counts.removed) options.push(['removed', '已移除']);
if (counts.unmapped) options.push(['unmapped', '未关联资料']);
filtersNode.innerHTML = options.map(function(item) {
var key = item[0];
var label = item[1];
@@ -1868,32 +1973,33 @@ export function createSidebarPageSettingsRuntime(context) {
}
}
function renderKnowledgeRagSources(popover, entries, status) {
function renderKnowledgeRagSources(popover, entries, status, providerDocs, activeBase) {
var sourcesNode = popover.querySelector('[data-testid="mnote-knowledge-rag-sources"]');
if (!(sourcesNode instanceof HTMLElement)) return;
var scopedProviderDocs = Array.isArray(providerDocs) ? providerDocs : knowledgeRagProviderDocuments(status || {});
var registryRows = (Array.isArray(entries) ? entries : []).map(function(entry) {
return { kind: 'registry', category: knowledgeRagRegistryEntryCategory(entry), entry: entry };
});
var rows = registryRows.concat(knowledgeRagUnmappedProviderRows(status || {}, entries));
var rows = registryRows.concat(knowledgeRagUnmappedProviderRows(status || {}, entries, scopedProviderDocs));
renderKnowledgeRagSourceFilters(popover, rows);
var filter = knowledgeRagCurrentFilter();
var items = rows.filter(function(row) {
return knowledgeRagVisibleForFilter(row, filter);
});
if (!rows.length) {
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有登记到知识库 provider 的资料来源</div>';
var emptyName = activeBase && activeBase.name ? activeBase.name : '当前资料库';
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">' + escapeHtml(emptyName) + ' 还没有资料</div>';
return;
}
if (!items.length) {
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">当前分类没有资料来源</div>';
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">当前分类没有资料</div>';
return;
}
var pipelineProgress = knowledgeRagPipelineProgress(status || {});
var providerName = knowledgeRagProviderName(status || {});
var providerDocs = knowledgeRagProviderDocuments(status || {});
var providerDocsById = new Map();
var providerDocsByPath = new Map();
providerDocs.forEach(function(doc) {
scopedProviderDocs.forEach(function(doc) {
var docId = knowledgeRagProviderDocId(doc);
var filePath = String(doc && doc.filePath || '').trim();
if (docId) providerDocsById.set(docId, doc);
@@ -1907,28 +2013,28 @@ export function createSidebarPageSettingsRuntime(context) {
var providerId = knowledgeRagProviderDocId(doc);
var providerPath = String(doc.filePath || '');
var docStatus = knowledgeRagProviderStatusText(doc.status || doc.statusGroup || doc.processingStatus || doc.processing_status);
var docStatusLabel = knowledgeRagUserStatusLabel(docStatus) || '已记录';
var failedDoc = row.category === 'unmapped_failed';
var providerProgress = pipelineProgress && pipelineProgress.docId && pipelineProgress.docId === providerId ? pipelineProgress : null;
var knowledgeId = knowledgeRagProviderKnowledgeId(doc);
var providerTitle = providerPath || providerId || 'Provider 文档';
var providerTitle = providerPath || providerId || '索引文档';
var chunkCount = knowledgeRagProviderChunkCount(doc);
var providerMeta = 'provider=' + providerName + ' · 未映射到 MNote source registry · ' + docStatus
+ (providerId ? ' · provider id ' + providerId : '')
+ (chunkCount ? ' · chunk ' + chunkCount : '')
+ (providerPath ? '' : ' · 定位降级');
var providerMeta = '未关联本地资料 · ' + docStatusLabel
+ (chunkCount ? ' · ' + chunkCount + ' 个片段' : '')
+ (providerPath ? '' : ' · 无法直接定位原文件');
if (!providerProgress && !failedDoc && knowledgeRagDocumentIsInFlight(doc)) {
providerProgress = knowledgeRagIndeterminateProgress(status || {}, doc, docStatus);
}
return '' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-kind="provider" data-knowledge-rag-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-provider-id="' + escapeHtml(providerId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(docStatus) + '" data-knowledge-rag-open-reference-mode="provider-reference-fallback" data-knowledge-rag-source-path="' + escapeHtml(providerPath || providerId) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + (failedDoc ? 'fault' : 'indexed') + '" title="' + escapeHtml(docStatus || 'Provider 文档') + '"></span>' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + (failedDoc ? 'fault' : 'indexed') + '" title="' + escapeHtml(docStatusLabel || '索引文档') + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(providerTitle) + '">' + escapeHtml(providerTitle) + '</strong>' +
'<span>' + escapeHtml(providerMeta) + '</span>' +
'<span class="mnote-weknora-source-aux"><i>' + escapeHtml(knowledgeId || 'provider-only') + '</i><i>' + escapeHtml(providerPath ? '引用可回跳' : '仅 provider 定位') + '</i></span>' +
'<span class="mnote-weknora-source-aux"><i>' + escapeHtml(knowledgeId || '索引记录') + '</i><i>' + escapeHtml(providerPath ? '引用可回跳' : '无法直接定位') + '</i></span>' +
knowledgeRagSourceProgressHtml(providerProgress) +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions"><button type="button" disabled>只读</button></div>' +
'<div class="mnote-knowledge-rag-source-actions"></div>' +
'</div>';
}
var entry = row.entry || {};
@@ -1944,11 +2050,12 @@ export function createSidebarPageSettingsRuntime(context) {
var indexed = entry && (entry.indexedAtMs || providerStatus === 'provider_mapped' || providerStatus === 'completed' || providerStatus === 'processed') && docId && !deleted && !stale && !failed && !deleting && !deletedDone;
var kind = deletedDone ? '' : (deleting ? 'indexing' : (failed || stale || deleted ? 'fault' : (indexed ? 'indexed' : 'indexing')));
var title = String(entry && entry.sourceRootRelativePath || entry && entry.lightRagFilePath || '');
var statusLabel = deletedDone ? 'Provider 已移除'
var rawProviderStatusLabel = knowledgeRagUserStatusLabel(providerStatus);
var statusLabel = deletedDone ? '已移除索引'
: deleting ? '删除已提交'
: deleted ? '已删除'
: stale ? '已过期'
: providerMissing ? 'Provider 索引缺失'
: providerMissing ? '索引记录缺失'
: failed ? '索引失败'
: indexed ? '已索引'
: (docId || providerStatus ? '正在索引' : '待提交');
@@ -1958,12 +2065,10 @@ export function createSidebarPageSettingsRuntime(context) {
var kbId = knowledgeRagEntryProviderKnowledgeBaseId(entry);
var metaParts = [
statusLabel,
'provider=' + providerName,
'MNote source registry',
kbId ? 'kb ' + kbId : '',
providerIds.length ? 'provider ids ' + providerIds.join(', ') : '',
chunkCount ? 'chunk ' + chunkCount : '',
providerStatus ? 'processing ' + providerStatus : ''
chunkCount ? chunkCount + ' 个片段' : '',
rawProviderStatusLabel && !indexed && rawProviderStatusLabel !== statusLabel ? rawProviderStatusLabel : '',
kbId ? '已绑定资料库' : '',
providerIds.length ? providerIds.length + ' 个索引记录' : ''
].filter(Boolean);
var meta = metaParts.join(' · ');
var sourcePath = String(entry && entry.sourceRootRelativePath || '');
@@ -1977,15 +2082,15 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-provider-ids="' + escapeHtml(providerIds.join(',')) + '" data-knowledge-rag-kb-id="' + escapeHtml(kbId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(providerStatus || statusLabel) + '" data-knowledge-rag-open-reference-mode="source-registry-reference" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(statusLabel) + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名来源') + '</strong>' +
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名资料') + '</strong>' +
'<span>' + escapeHtml(meta) + '</span>' +
'<span class="mnote-weknora-source-aux"><i>' + escapeHtml(kbId || '未绑定 KB') + '</i><i>' + escapeHtml(knowledgeId || '待生成 citation') + '</i></span>' +
knowledgeRagSourceProgressHtml(entryProgress) +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions">' +
'<button type="button" data-knowledge-rag-action="open-source-reference" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (sourcePath ? '' : ' disabled') + '>打开引用</button>' +
'<button type="button" data-knowledge-rag-action="reindex-source" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canReindex ? '' : ' disabled') + '>重索引</button>' +
'<button type="button" data-knowledge-rag-action="delete-source" data-knowledge-rag-delete-local-file-policy="preserve-local-file" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canDelete ? '' : ' disabled') + ' title="删除 provider index 与 registry 映射,不删除本地文件">除索引</button>' +
'<button type="button" data-knowledge-rag-action="open-source-reference" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (sourcePath ? '' : ' disabled') + '>打开</button>' +
'<button type="button" data-knowledge-rag-action="reindex-source" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canReindex ? '' : ' disabled') + '>重</button>' +
'<button type="button" data-knowledge-rag-action="delete-source" data-knowledge-rag-delete-local-file-policy="preserve-local-file" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canDelete ? '' : ' disabled') + ' title="只移除索引记录,不删除本地文件">除索引</button>' +
'</div>' +
'</div>';
}).join('');
@@ -2002,7 +2107,7 @@ export function createSidebarPageSettingsRuntime(context) {
statusNode.textContent = '知识库仅在本地工作区可用';
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
renderKnowledgeRagSources(popover, [], {}, [], null);
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
@@ -2012,7 +2117,7 @@ export function createSidebarPageSettingsRuntime(context) {
statusNode.textContent = '正在读取知识库 provider 状态...';
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
renderKnowledgeRagSources(popover, [], {}, [], null);
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
@@ -2020,7 +2125,7 @@ export function createSidebarPageSettingsRuntime(context) {
statusNode.textContent = '知识库 provider 状态读取失败:' + summary.error;
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
renderKnowledgeRagSources(popover, [], {}, [], null);
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
@@ -2046,12 +2151,21 @@ export function createSidebarPageSettingsRuntime(context) {
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
renderKnowledgeRagKnowledgeBaseControls(popover, status);
renderKnowledgeRagKnowledgeBaseControls(popover, status, entries, providerDocs);
renderKnowledgeRagKnowledgeBaseCards(popover, status, entries, providerDocs);
renderKnowledgeRagKnowledgeBaseHero(popover, status, entries, providerDocs);
var processingCount = knowledgeRagInFlightCount(entries, providerDocs);
var bases = knowledgeRagKnowledgeBases(status || {});
var selectedBaseId = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
var activeBase = bases.find(function(base) { return base.providerKbId === selectedBaseId; }) || bases[0] || null;
var activeStats = activeBase ? knowledgeRagBaseStats(activeBase, entries, providerDocs) : null;
var activeEntries = activeBase ? knowledgeRagEntriesForKb(entries, activeBase.providerKbId) : entries;
var activeProviderDocs = activeBase ? knowledgeRagProviderDocsForKb(providerDocs, activeBase.providerKbId) : providerDocs;
statusNode.textContent = healthy
? providerLabel + ' 就绪,MNote 登记 ' + entries.length + ' 个来源,provider 文档 ' + providerDocs.length + ' 个'
: providerLabel + ' 未就绪';
? (activeBase
? activeBase.name + ' · 服务可用 · ' + activeStats.sourceCount + ' 个资料' + (activeStats.processingCount ? ' · ' + activeStats.processingCount + ' 个处理中' : '')
: '知识库服务可用 · ' + entries.length + ' 个资料' + (processingCount ? ' · ' + processingCount + ' 个处理中' : ''))
: '知识库服务不可用,页面仍可编辑';
if (metaNode instanceof HTMLElement) {
var dashboardUrl = publicKnowledgeRagDashboardUrl(status);
var inputDir = String((status.legacyFallback && status.legacyFallback.inputDir) || status.inputDir || '');
@@ -2072,15 +2186,15 @@ export function createSidebarPageSettingsRuntime(context) {
: 'unknown';
metaNode.innerHTML = '' +
knowledgeRagKbSummaryHtml(status, entries, providerDocs) +
'<div><span>Provider</span><code>' + escapeHtml(providerLabel) + '</code></div>' +
'<div><span>Boundary</span><code>MNote source registry / LightRAG sidecar locator</code></div>' +
'<div><span>Dashboard</span><code>' + escapeHtml(dashboardUrl || '未配置') + '</code></div>' +
'<div><span>Model</span><code>' + escapeHtml(llmModel || '未上报') + (vlmModel ? ' / VLM ' + escapeHtml(vlmModel) : '') + '</code></div>' +
'<div><span>服务</span><code>' + escapeHtml(providerLabel) + '</code></div>' +
'<div><span>边界</span><code>MNote source registry / LightRAG sidecar locator</code></div>' +
'<div><span>控制台</span><code>' + escapeHtml(dashboardUrl || '未配置') + '</code></div>' +
'<div><span>模型</span><code>' + escapeHtml(llmModel || '未上报') + (vlmModel ? ' / VLM ' + escapeHtml(vlmModel) : '') + '</code></div>' +
'<div><span>Rerank</span><code>' + escapeHtml(rerankLabel + (rerankBinding ? ' · ' + rerankBinding : '') + (rerankModel ? ' · ' + rerankModel : '')) + '</code></div>' +
'<div><span>Input</span><code>' + escapeHtml(inputDir || '未配置') + '</code></div>' +
'<div><span>Storage</span><code>' + escapeHtml(workingDir || '未上报') + '</code></div>';
'<div><span>输入目录</span><code>' + escapeHtml(inputDir || '未配置') + '</code></div>' +
'<div><span>存储目录</span><code>' + escapeHtml(workingDir || '未上报') + '</code></div>';
}
renderKnowledgeRagSources(popover, entries, status);
renderKnowledgeRagSources(popover, activeEntries, status, activeProviderDocs, activeBase);
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = !publicKnowledgeRagDashboardUrl(status);
}
@@ -2165,8 +2279,11 @@ export function createSidebarPageSettingsRuntime(context) {
function addKnowledgeRagSourceInput() {
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
var composer = popover.querySelector('[data-knowledge-rag-source-composer]');
if (!(node instanceof HTMLElement)) return;
node.insertAdjacentHTML('beforeend', knowledgeRagSourceInputRow(''));
var shouldAppend = !(composer instanceof HTMLElement && composer.hidden);
setKnowledgeRagSourceComposerVisible(true);
if (shouldAppend) node.insertAdjacentHTML('beforeend', knowledgeRagSourceInputRow(''));
var inputs = Array.from(node.querySelectorAll('[data-knowledge-rag-source-input]'));
var last = inputs[inputs.length - 1];
if (last instanceof HTMLInputElement) last.focus();
@@ -2202,6 +2319,7 @@ export function createSidebarPageSettingsRuntime(context) {
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (!(node instanceof HTMLElement)) return;
setKnowledgeRagSourceComposerVisible(true);
var existing = knowledgeRagSourceInputValues();
var next = existing.concat(selectedPaths).filter(function(value, index, list) {
return list.indexOf(value) === index;
@@ -2249,6 +2367,78 @@ export function createSidebarPageSettingsRuntime(context) {
if (node instanceof HTMLElement) node.innerHTML = knowledgeRagSourceInputRow('');
}
function setKnowledgeRagCreateCardVisible(visible) {
var popover = ensureKnowledgeRagSettingsPopover();
var card = popover.querySelector('[data-testid="mnote-weknora-kb-controls"]');
var statusNode = popover.querySelector('[data-knowledge-rag-create-kb-status]');
if (card instanceof HTMLElement) card.hidden = !visible;
if (statusNode instanceof HTMLElement && visible) statusNode.hidden = true;
if (visible) {
var input = popover.querySelector('[data-knowledge-rag-create-kb-name]');
if (input instanceof HTMLInputElement) input.focus();
}
}
function toggleKnowledgeRagCreateCard() {
var popover = ensureKnowledgeRagSettingsPopover();
var card = popover.querySelector('[data-testid="mnote-weknora-kb-controls"]');
setKnowledgeRagCreateCardVisible(!(card instanceof HTMLElement) || card.hidden);
}
function setKnowledgeRagCreateStatus(text, status) {
var popover = ensureKnowledgeRagSettingsPopover();
var statusNode = popover.querySelector('[data-knowledge-rag-create-kb-status]');
if (statusNode instanceof HTMLElement) {
statusNode.textContent = String(text || '');
statusNode.setAttribute('data-status', status || 'done');
statusNode.hidden = !text;
}
}
async function deleteKnowledgeRagKnowledgeBase() {
var status = currentKnowledgeRagSummary().status || {};
var bases = knowledgeRagKnowledgeBases(status);
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status);
if (!selected || selected === 'lightrag-default') {
setKnowledgeRagCreateStatus('默认资料库不能删除', 'failed');
return;
}
var activeBase = bases.find(function(base) { return base.providerKbId === selected; }) || null;
var registry = status.registry && typeof status.registry === 'object' ? status.registry : {};
var entries = Array.isArray(registry.entries) ? registry.entries : [];
var activeStats = activeBase ? knowledgeRagBaseStats(activeBase, entries, knowledgeRagProviderDocuments(status)) : null;
if (activeStats && (activeStats.sourceCount > 0 || activeStats.processingCount > 0)) {
setKnowledgeRagCreateStatus('请先移除资料后再删除资料库', 'failed');
return;
}
setKnowledgeRagCreateStatus('删除中', 'running');
try {
var response = await fetch('/api/knowledge-rag/knowledge-bases', {
method: 'DELETE',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri(),
providerKnowledgeBaseId: selected
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_delete_kb_' + response.status);
}
pageUiState.knowledgeRagOptimisticBases = knowledgeRagOptimisticBases().filter(function(base) {
return knowledgeRagBaseProviderKbId(base) !== selected;
});
pageUiState.knowledgeRagSelectedProviderKnowledgeBaseId = 'lightrag-default';
setKnowledgeRagCreateStatus('已删除资料库', 'done');
await loadKnowledgeRagStatus(true, { reason: 'delete-kb', notifyUpdated: true });
renderKnowledgeRagSettings();
notifyKnowledgeRagSourceUpdated({ action: 'delete-kb', result: payload, providerKnowledgeBaseId: selected });
} catch (error) {
setKnowledgeRagCreateStatus(error instanceof Error ? error.message : String(error), 'failed');
}
}
async function createKnowledgeRagKnowledgeBase() {
if (!knowledgeRagIsAvailable()) return;
var popover = ensureKnowledgeRagSettingsPopover();
@@ -2259,7 +2449,7 @@ export function createSidebarPageSettingsRuntime(context) {
var description = descriptionInput instanceof HTMLInputElement ? descriptionInput.value.trim() : '';
if (!name) {
if (statusNode instanceof HTMLElement) {
statusNode.textContent = '请填写 KB 名称';
statusNode.textContent = '请填写资料库名称';
statusNode.setAttribute('data-status', 'failed');
statusNode.hidden = false;
}
@@ -2308,6 +2498,7 @@ export function createSidebarPageSettingsRuntime(context) {
statusNode.setAttribute('data-status', 'done');
statusNode.hidden = false;
}
setKnowledgeRagCreateCardVisible(false);
await loadKnowledgeRagStatus(true, { reason: 'create-kb', notifyUpdated: true });
if (providerKnowledgeBaseId) {
pageUiState.knowledgeRagSelectedProviderKnowledgeBaseId = providerKnowledgeBaseId;
@@ -2385,6 +2576,7 @@ export function createSidebarPageSettingsRuntime(context) {
setKnowledgeRagInputStatuses(done, 'done');
await loadKnowledgeRagStatus(true);
resetKnowledgeRagSourceInputs();
setKnowledgeRagSourceComposerVisible(false);
notifyKnowledgeRagSourceUpdated({ action: 'ingest', sources: sources, result: payload });
scheduleKnowledgeRagStatusBridge('ingest');
} catch (error) {
@@ -2590,10 +2782,10 @@ export function createSidebarPageSettingsRuntime(context) {
if (!input || !resultsEl) return;
var query = (input.value || '').trim();
if (!query) {
resultsEl.innerHTML = '<span class="mnote-extensions-error">请输入检索关键词</span>';
resultsEl.innerHTML = '<div class="mnote-kb-search-empty">请输入检索关键词</div>';
return;
}
resultsEl.innerHTML = '<span class="mnote-extensions-loading">检索中...</span>';
resultsEl.innerHTML = '<div class="mnote-kb-search-empty">检索中...</div>';
try {
var params = new URLSearchParams();
params.set('query', query);
@@ -2623,7 +2815,7 @@ export function createSidebarPageSettingsRuntime(context) {
? data.results
: (Array.isArray(data.references) ? data.references : (Array.isArray(data) ? data : []));
if (!results.length) {
resultsEl.innerHTML = '<div class="mnote-extensions-item"><span>未找到匹配结果</span></div>';
resultsEl.innerHTML = '<div class="mnote-kb-search-empty">未找到匹配结果</div>';
return;
}
var html = '';
@@ -2640,7 +2832,7 @@ export function createSidebarPageSettingsRuntime(context) {
html += '<div class="mnote-kb-search-result-item" data-kb-rag-search-result="true" data-knowledge-rag-provider="' + escapeHtml(knowledgeRagProviderName(currentKnowledgeRagSummary().status || {})) + '" data-knowledge-rag-kb-id="' + escapeHtml(providerKnowledgeBaseId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(providerKnowledgeId) + '" data-knowledge-rag-chunk-id="' + escapeHtml(providerChunkId) + '" data-knowledge-rag-source-path="' + escapeHtml(String(source)) + '">' +
'<div class="mnote-kb-search-result-header">' +
'<span class="mnote-kb-search-result-index">#' + (i + 1) + '</span>' +
'<span class="mnote-kb-search-result-score" data-kb-rag-score-label="provider-ranking-score" data-kb-rag-score-is-similarity-percent="false">provider 排序分: ' + score + '</span>' +
'<span class="mnote-kb-search-result-score" data-kb-rag-score-label="relevance" data-kb-rag-score-is-similarity-percent="false">相关度 ' + score + '</span>' +
'</div>' +
'<div class="mnote-kb-search-result-source">' + escapeHtml(String(source)) + '</div>' +
'<div class="mnote-kb-search-result-preview">' + escapeHtml(preview) + (content.length > 200 ? '...' : '') + '</div>' +
@@ -2650,7 +2842,7 @@ export function createSidebarPageSettingsRuntime(context) {
});
resultsEl.innerHTML = html;
} catch (err) {
resultsEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">检索失败: ' + escapeHtml(String(err.message || err)) + '</span></div>';
resultsEl.innerHTML = '<div class="mnote-kb-search-empty is-error">检索失败' + escapeHtml(String(err.message || err)) + '</div>';
}
}
@@ -3312,6 +3504,7 @@ export function createSidebarPageSettingsRuntime(context) {
loadKnowledgeRagStatus,
openKnowledgeRagDashboard,
createKnowledgeRagKnowledgeBase,
deleteKnowledgeRagKnowledgeBase,
loadKnowledgeRagSearchParamsToForm,
performKnowledgeRagSearch,
saveKnowledgeRagSearchParams,
@@ -3323,6 +3516,7 @@ export function createSidebarPageSettingsRuntime(context) {
setKnowledgeRagSelectedProviderKnowledgeBaseId,
setKnowledgeRagSourceFilter,
useKnowledgeRagFileTreeSelection,
toggleKnowledgeRagCreateCard,
togglePageSettingsPopover,
updatePageSettingsTriggerState,
writeGlobalShowHeadingNumbers,
@@ -41,6 +41,51 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
};
}
function selectSidebarPageTreeDocument(nodeId, options) {
var normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) return false;
var targetRow = document.querySelector(
'#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="' + cssEscape(normalizedNodeId) + '"]'
);
if (!(targetRow instanceof HTMLElement)) return false;
var parentId = String(targetRow.getAttribute('data-parent-id') || '').trim();
var guard = 0;
while (parentId && guard < 512) {
var parentRow = document.querySelector(
'#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="' + cssEscape(parentId) + '"]'
);
if (!(parentRow instanceof HTMLElement)) break;
if (parentRow.getAttribute('aria-expanded') !== 'true') {
toggleChildren(parentRow, parentRow.querySelector('[data-rust-action="toggle"]'));
}
parentId = String(parentRow.getAttribute('data-parent-id') || '').trim();
guard += 1;
}
document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"][data-active="true"]').forEach(function(row) {
if (!(row instanceof HTMLElement)) return;
row.setAttribute('data-active', 'false');
row.setAttribute('aria-selected', 'false');
row.removeAttribute('aria-current');
});
targetRow.setAttribute('data-active', 'true');
targetRow.setAttribute('aria-selected', 'true');
targetRow.setAttribute('aria-current', 'page');
if (!options || options.scrollIntoView !== false) {
try { targetRow.scrollIntoView({ block: 'nearest' }); } catch (_) {}
}
return true;
}
function selectActiveTreeDocument(nodeId, treeView, options) {
if (treeView === 'filetree') {
selectSidebarFileTreeDocument(nodeId, options);
return;
}
selectSidebarPageTreeDocument(nodeId, options);
}
function navigateToDocument(nodeId, workspaceId, options) {
if (!nodeId) return;
var treeView = normalizeSidebarTreeMode(options && options.treeView ? options.treeView : activeSidebarTreeMode());
@@ -49,22 +94,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
if (typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === 'function') {
window.__mnoteDocumentPaneRuntime.activatePageTab();
}
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach(function(row) {
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'false');
row.setAttribute('data-selected', 'false');
}
});
document.querySelectorAll('.tree-row[data-node-id="' + cssEscape(nodeId) + '"], .tree-row[data-document-id="' + cssEscape(nodeId) + '"], .tree-row[data-doc-id="' + cssEscape(nodeId) + '"]').forEach(function(row) {
if (row instanceof HTMLElement) {
if (row.getAttribute('data-shell-mode') === 'filetree') {
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === 'doc:' + nodeId));
} else {
row.setAttribute('data-active', 'true');
}
}
});
selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true });
selectActiveTreeDocument(nodeId, treeView, { scrollIntoView: true });
return;
}
var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin);
@@ -88,7 +118,9 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
}).then(function(){
if (mnoteNavigationInFlight === url) mnoteNavigationInFlight = '';
document.documentElement.removeAttribute('data-mnote-navigation-pending');
selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true });
if (treeView === 'filetree') {
selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true });
}
}).catch(function(error){
console.warn('mnote pane 内导航失败,将回退整页导航', error);
if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') {
@@ -110,6 +142,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
async function createPage(trigger, parentId) {
var workspaceId = resolveWorkspaceId(trigger);
var effectiveParentId = (parentId || trigger.getAttribute('data-parent-id') || '').trim();
var treeView = activeSidebarTreeMode();
if (!workspaceId) return;
var result = await dispatchTreeCommand(trigger, {
action: 'create',
@@ -126,10 +159,12 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
if (typeof refreshLocalFolderAfterCommand === 'function') {
await refreshLocalFolderAfterCommand('create', result, { parentId: effectiveParentId || null });
}
selectSidebarFileTreeDocument(nextDocumentId, localCreateSelectionOptions(result));
if (treeView === 'filetree') {
selectSidebarFileTreeDocument(nextDocumentId, localCreateSelectionOptions(result));
}
document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId);
}
navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() });
navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: treeView });
}
function startPageDrag(pageRow, event) {
@@ -259,6 +294,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
return {
navigateToDocument,
createPage,
selectSidebarPageTreeDocument,
startPageDrag,
readPageDragNodeId,
clearPageDropFeedback,
@@ -46,6 +46,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var fileTreeVisibleHydrateTimer = 0;
var fileTreeVisibleHydrateQueued = false;
var fileTreeCommandBatchRefreshParents = new Map();
var fileTreeRevealRequests = new Map();
var FILETREE_VISIBLE_EXPANDED_HYDRATE_MAX = 20;
var FILETREE_VISIBLE_EXPANDED_HYDRATE_BATCH = 2;
var FILETREE_EXPANSION_STORAGE_KEY = 'mnote.localFileTree.expandedRelativePaths.v1';
@@ -1795,32 +1796,46 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
ensureFileTreeLazyCacheScope();
var relativePath = normalizeFileTreeRelativePath(input.relativePath || input.relative_path || '');
var rowId = String(input.rowId || input.row_id || '').trim();
var parentChain = fileTreeParentChainForRelativePath(relativePath);
for (var index = 0; index < parentChain.length; index += 1) {
var parentRelativePath = parentChain[index];
var parentRow = fileTreeRowByRelativePath(parentRelativePath);
if (!(parentRow instanceof HTMLElement)) continue;
var button = parentRow.querySelector('[data-rust-action="toggle"]');
await getFileTreeChildren(parentRelativePath);
renderCachedFileTreeChildren(parentRow, button, parentRelativePath);
setTreeRowExpanded(parentRow, button, true);
}
var targetRow = rowId
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]')
: null;
if (!(targetRow instanceof HTMLElement) && relativePath) {
targetRow = fileTreeRowByRelativePath(relativePath);
}
if (!(targetRow instanceof HTMLElement)) return false;
var targetRowId = String(targetRow.getAttribute('data-row-id') || rowId || '').trim();
if (targetRowId) {
fileTreeViewState.selectedRowIds = new Set([targetRowId]);
fileTreeViewState.focusedRowId = targetRowId;
fileTreeViewState.activeRowId = targetRowId;
}
reprojectFileTreeSelectionState();
try { targetRow.scrollIntoView({ block: 'nearest' }); } catch (_) {}
return true;
var requestKey = [rootUri, currentFileTreeScope(), relativePath, rowId].join('\n');
if (fileTreeRevealRequests.has(requestKey)) return fileTreeRevealRequests.get(requestKey);
var request = (async function() {
var parentChain = fileTreeParentChainForRelativePath(relativePath);
for (var index = 0; index < parentChain.length; index += 1) {
var parentRelativePath = parentChain[index];
var parentRow = fileTreeRowByRelativePath(parentRelativePath);
if (!(parentRow instanceof HTMLElement)) continue;
var button = parentRow.querySelector('[data-rust-action="toggle"]');
var childrenLoaded = parentRow.getAttribute('data-filetree-children-loaded') === 'true';
var expanded = parentRow.getAttribute('aria-expanded') === 'true';
if (!childrenLoaded) {
await getFileTreeChildren(parentRelativePath);
renderCachedFileTreeChildren(parentRow, button, parentRelativePath);
}
if (!expanded) {
setTreeRowExpanded(parentRow, button, true);
}
}
var targetRow = rowId
? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]')
: null;
if (!(targetRow instanceof HTMLElement) && relativePath) {
targetRow = fileTreeRowByRelativePath(relativePath);
}
if (!(targetRow instanceof HTMLElement)) return false;
var targetRowId = String(targetRow.getAttribute('data-row-id') || rowId || '').trim();
if (targetRowId) {
fileTreeViewState.selectedRowIds = new Set([targetRowId]);
fileTreeViewState.focusedRowId = targetRowId;
fileTreeViewState.activeRowId = targetRowId;
}
reprojectFileTreeSelectionState();
try { targetRow.scrollIntoView({ block: 'nearest' }); } catch (_) {}
return true;
})().finally(function() {
fileTreeRevealRequests.delete(requestKey);
});
fileTreeRevealRequests.set(requestKey, request);
return request;
}
function toggleChildren(row, button) {
@@ -221,6 +221,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const loadKnowledgeRagStatus = (...args) => sidebarPageSettings.loadKnowledgeRagStatus(...args);
const openKnowledgeRagDashboard = (...args) => sidebarPageSettings.openKnowledgeRagDashboard(...args);
const createKnowledgeRagKnowledgeBase = (...args) => sidebarPageSettings.createKnowledgeRagKnowledgeBase(...args);
const deleteKnowledgeRagKnowledgeBase = (...args) => sidebarPageSettings.deleteKnowledgeRagKnowledgeBase(...args);
const toggleKnowledgeRagCreateCard = (...args) => sidebarPageSettings.toggleKnowledgeRagCreateCard(...args);
const setActiveKnowledgeRagDetailTab = (...args) => sidebarPageSettings.setActiveKnowledgeRagDetailTab(...args);
const performKnowledgeRagSearch = (...args) => sidebarPageSettings.performKnowledgeRagSearch(...args);
const saveKnowledgeRagSearchParams = (...args) => sidebarPageSettings.saveKnowledgeRagSearchParams(...args);
@@ -367,6 +369,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
openPageTreeContextMenu: (...args) => openPageTreeContextMenu(...args),
updateTitleEverywhere: (...args) => updateTitleEverywhere(...args),
});
const selectSidebarPageTreeDocument = (...args) => sidebarPageTree.selectSidebarPageTreeDocument(...args);
const fileTreePageTitle = (...args) => sidebarTreeLiveApply.fileTreePageTitle(...args);
const isFileTreePageRow = (...args) => sidebarTreeLiveApply.isFileTreePageRow(...args);
const normalizeFileTreePageRenameTitle = (...args) => sidebarTreeLiveApply.normalizeFileTreePageRenameTitle(...args);
@@ -476,6 +479,22 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return true;
}
function knowledgeHostUrl(options) {
options = options || {};
var knowledgeUrl = new URL('/knowledge', window.location.origin);
var workspaceId = String(options.workspaceId || currentWorkspaceId() || resolveWorkspaceId(document.body) || '').trim();
var sourceKind = String(options.sourceKind || currentSourceKind() || '').trim();
var rootUri = String(options.rootUri || currentRootUri() || '').trim();
var treeView = String(options.treeView || activeSidebarTreeMode() || '').trim();
var fileTreeScope = String(options.fileTreeScope || currentFileTreeScope() || '').trim();
if (workspaceId) knowledgeUrl.searchParams.set('workspaceId', workspaceId);
if (sourceKind) knowledgeUrl.searchParams.set('sourceKind', sourceKind);
if (rootUri) knowledgeUrl.searchParams.set('rootUri', rootUri);
if (treeView) knowledgeUrl.searchParams.set('treeView', treeView);
if (fileTreeScope) knowledgeUrl.searchParams.set('fileTreeScope', fileTreeScope);
return knowledgeUrl.pathname + knowledgeUrl.search;
}
function currentTopbarTitle() {
var title = document.querySelector('[data-page-title-current="true"]');
return title && title.textContent ? title.textContent.trim() : '无标题';
@@ -2058,6 +2077,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args),
openEditorAttachmentNewWindow: (...args) => openEditorAttachmentNewWindow(...args),
openLocalResourceInActiveTab: (...args) => openLocalResourceInActiveTab(...args),
knowledgeHostUrl,
refreshLocalFolderSidebarSnapshot,
removeFileTreeAssetRow,
revealFileTreeResource,
@@ -2162,7 +2182,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
'<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="knowledge" 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="knowledge" role="switch" aria-checked="false" aria-label="全盘知识库检索"></button></span>' +
'<span class="wolai-search-switch-control"><span>页面内搜索</span><button type="button" class="wolai-search-switch" data-search-switch="page" role="switch" aria-checked="false" aria-label="页面内搜索"></button></span>' +
'<span class="wolai-search-switch-control"><span>折叠同来源</span><button type="button" class="wolai-search-switch is-on" data-search-switch="collapseSource" role="switch" aria-checked="true" aria-label="默认折叠相同来源结果"></button></span>' +
'</div>' +
@@ -2174,7 +2194,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
document.body.appendChild(overlay);
var input = overlay.querySelector('[data-testid="wolai-search-input"]');
var closeButton = overlay.querySelector('[data-testid="wolai-search-close"]');
applySearchSwitchState(overlay, { knowledge: true, collapseSource: readSearchCollapseSourcesDefault() });
applySearchSwitchState(overlay, { knowledge: false, collapseSource: readSearchCollapseSourcesDefault() });
var initialKnowledgeModeSelect = overlay.querySelector('[data-search-knowledge-mode]');
if (initialKnowledgeModeSelect && 'value' in initialKnowledgeModeSelect) initialKnowledgeModeSelect.value = 'exact';
if (input) input.addEventListener('input', function() {
@@ -2986,10 +3006,20 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var knowledgeHostTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-host"]');
if (knowledgeHostTrigger) {
var hostUrl = knowledgeHostUrl();
if (knowledgeHostTrigger instanceof HTMLAnchorElement) knowledgeHostTrigger.setAttribute('href', hostUrl);
if (knowledgeHostTrigger instanceof HTMLAnchorElement && (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1)) return;
e.preventDefault();
window.location.href = hostUrl;
return;
}
var knowledgeRagSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-rag-settings"]');
if (knowledgeRagSettingsTrigger) {
e.preventDefault();
window.location.href = '/knowledge';
window.location.href = knowledgeHostUrl();
return;
}
@@ -3069,7 +3099,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
void createKnowledgeRagKnowledgeBase();
return;
}
if (knowledgeRagActionName === 'delete-kb') {
void deleteKnowledgeRagKnowledgeBase();
return;
}
if (knowledgeRagActionName === 'toggle-create-kb') {
toggleKnowledgeRagCreateCard();
return;
}
if (knowledgeRagActionName === 'focus-create-kb') {
toggleKnowledgeRagCreateCard();
var createKbInput = document.querySelector('[data-knowledge-rag-create-kb-name]');
if (createKbInput instanceof HTMLInputElement) createKbInput.focus();
return;
@@ -3870,21 +3909,44 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
});
syncSidebarFileTreeSelection();
schedulePendingLocalFolderRestoreFocus();
window.addEventListener('mnote:primary-document-activated', function(event) {
var detail = event && event.detail ? event.detail : {};
var recentPageOptionsApply = { documentId: '', at: 0 };
function applyPrimaryPageOptionsOnce(documentId) {
var normalizedDocumentId = searchText(documentId || currentDocumentId() || '');
pageUiState.pageOptions = null;
applyPageOptionsToShell();
if (isPageSettingsOpen()) renderPageSettingsPopover();
selectSidebarFileTreeDocument(detail.documentId, { scrollIntoView: false });
recentPageOptionsApply = { documentId: normalizedDocumentId, at: Date.now() };
}
function hasRecentPageOptionsApply(documentId) {
var normalizedDocumentId = searchText(documentId || currentDocumentId() || '');
return normalizedDocumentId
&& recentPageOptionsApply.documentId === normalizedDocumentId
&& Date.now() - recentPageOptionsApply.at < 1000;
}
window.addEventListener('mnote:primary-document-activated', function(event) {
var detail = event && event.detail ? event.detail : {};
if (!hasRecentPageOptionsApply(detail.documentId)) {
applyPrimaryPageOptionsOnce(detail.documentId);
}
if (detail.pageBlockNavigation === true) {
detail.sidebarSelectionHandled = false;
return;
}
if (activeSidebarTreeMode() === 'filetree') {
detail.sidebarSelectionHandled = selectSidebarFileTreeDocument(detail.documentId, {
rootUri: detail.rootUri || currentRootUri(),
scrollIntoView: false
});
} else {
detail.sidebarSelectionHandled = selectSidebarPageTreeDocument(detail.documentId, { scrollIntoView: false });
}
});
window.addEventListener('mnote:page-aggregate-synced', function(event) {
var detail = event && event.detail ? event.detail : {};
if (detail.scriptId && detail.scriptId !== '__MNOTE_PAGE_AGGREGATE__') return;
var documentId = searchText(detail.documentId || '');
if (documentId && currentDocumentId() && documentId !== currentDocumentId()) return;
pageUiState.pageOptions = null;
applyPageOptionsToShell();
if (isPageSettingsOpen()) renderPageSettingsPopover();
applyPrimaryPageOptionsOnce(documentId);
});
restoreSidebarTreeTab();
if (window.location.pathname === '/debug/knowledge-rag' || window.location.pathname === '/knowledge') {
+13 -2
View File
@@ -1017,8 +1017,19 @@ fn render_recent_navigation_items(records: &[NavigationRecentRecord]) -> String
}
fn render_navigation_link(title: &str, href: &str, kind: &str, relative_path: &str) -> String {
let is_page = kind == "page";
let class_name = if is_page {
"mnote-navigation-page__item mnote-page-block-link"
} else {
"mnote-navigation-page__item"
};
let page_attributes = if is_page {
r#" data-area="page-block" data-block-type="page""#
} else {
""
};
format!(
r#"<a class="mnote-navigation-page__item" data-navigation-item-kind="{}" data-local-relative-path="{}" href="{}">{}</a>"#,
r#"<a class="{class_name}" data-navigation-item-kind="{}" data-local-relative-path="{}"{page_attributes} href="{}">{}</a>"#,
escape_html(kind),
escape_html(relative_path),
escape_html(href),
@@ -1079,7 +1090,7 @@ fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id: &str) -> String {
let mut href = format!(
"/documents/{}?sourceKind=local_folder&rootUri={}&treeView=filetree",
"/documents/{}?sourceKind=local_folder&rootUri={}",
query_escape(document_id),
query_escape(root_uri)
);
+522 -35
View File
@@ -7,6 +7,7 @@ use axum::body::Body;
use axum::extract::{Extension, Json, Query, State};
use axum::http::{header, StatusCode};
use axum::response::Response;
use control_plane::{ControlPlaneError, KnowledgeBaseRecord, UpsertKnowledgeBaseInput};
use core_protocol::evidence::{
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceResourceKind,
};
@@ -131,6 +132,15 @@ pub struct KnowledgeRagCreateKnowledgeBaseRequest {
pub default_tool_enabled: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagDeleteKnowledgeBaseRequest {
pub workspace_id: Option<String>,
pub root_uri: String,
pub knowledge_base_id: Option<String>,
pub provider_knowledge_base_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnowledgeRagOpenReferenceRequest {
@@ -334,6 +344,170 @@ fn filter_knowledge_bases_for_actor(bases: &mut KnowledgeRagKnowledgeBaseRegistr
.retain(|base| knowledge_base_visible_to_actor(base, actor_id));
}
fn control_plane_knowledge_base_to_registry_base(
record: &KnowledgeBaseRecord,
) -> KnowledgeRagKnowledgeBase {
KnowledgeRagKnowledgeBase {
base_id: record.id.clone(),
user_id: record.user_id.clone(),
workspace_id: record.workspace_id.clone().unwrap_or_default(),
root_uri: record.root_uri.clone().unwrap_or_default(),
name: record.name.clone(),
description: record.description.clone(),
provider: record.provider.clone(),
provider_kb_id: record.provider_kb_id.clone(),
default_tool_enabled: record.default_tool_enabled,
can_write: record.can_write,
source_count: record.source_count.max(0) as usize,
chunk_count: record.chunk_count.max(0) as usize,
status: record.status.clone(),
created_at_ms: 0,
updated_at_ms: 0,
}
}
fn control_plane_knowledge_base_error(
error: ControlPlaneError,
context: &RequestContext,
) -> WebError {
match error {
ControlPlaneError::NotFound(message) => WebError::new(
StatusCode::NOT_FOUND,
"knowledge_rag_control_plane_kb_not_found",
message,
),
ControlPlaneError::Conflict(message) => WebError::new(
StatusCode::CONFLICT,
"knowledge_rag_control_plane_kb_conflict",
message,
),
ControlPlaneError::InvalidInput(message) => {
WebError::bad_request_code("knowledge_rag_control_plane_kb_invalid", message)
}
ControlPlaneError::Unauthorized(message) | ControlPlaneError::SessionExpired(message) => {
WebError::new(
StatusCode::FORBIDDEN,
"knowledge_rag_control_plane_kb_forbidden",
message,
)
}
ControlPlaneError::Timeout(message) => {
WebError::gateway_timeout_code("knowledge_rag_control_plane_kb_timeout", message)
}
ControlPlaneError::RateLimit(message) => WebError::new(
StatusCode::TOO_MANY_REQUESTS,
"knowledge_rag_control_plane_kb_rate_limited",
message,
),
ControlPlaneError::Storage(message) => WebError::service_unavailable_code(
"knowledge_rag_control_plane_kb_storage_failed",
message,
),
}
.with_context(context)
}
fn control_plane_knowledge_bases_registry(
state: &AppState,
context: &RequestContext,
workspace_id: &str,
root_uri: &str,
) -> Result<KnowledgeRagKnowledgeBaseRegistry, WebError> {
let records = state
.control_plane()
.list_knowledge_bases(
context.auth.actor_id.as_str(),
Some(workspace_id),
Some(root_uri),
)
.map_err(|error| control_plane_knowledge_base_error(error, context))?;
Ok(KnowledgeRagKnowledgeBaseRegistry {
schema: KNOWLEDGE_BASES_SCHEMA.to_string(),
workspace_id: workspace_id.to_string(),
root_uri: root_uri.to_string(),
updated_at_ms: now_ms(),
bases: records
.iter()
.map(control_plane_knowledge_base_to_registry_base)
.collect(),
})
}
fn knowledge_base_source_counts(
sources: &KnowledgeRagSourceRegistry,
provider_kb_id: &str,
) -> (i64, i64) {
let source_count = sources
.entries
.iter()
.filter(|entry| {
!entry.stale
&& entry.deleted_at_ms.is_none()
&& entry.provider_knowledge_base_id.as_deref() == Some(provider_kb_id)
})
.count() as i64;
(source_count, 0)
}
fn control_plane_knowledge_base_upsert_input(
record: &KnowledgeBaseRecord,
source_count: i64,
chunk_count: i64,
) -> UpsertKnowledgeBaseInput {
UpsertKnowledgeBaseInput {
id: Some(record.id.clone()),
user_id: record.user_id.clone(),
workspace_id: record.workspace_id.clone(),
root_uri: record.root_uri.clone(),
name: record.name.clone(),
description: record.description.clone(),
provider: record.provider.clone(),
provider_kb_id: record.provider_kb_id.clone(),
default_tool_enabled: record.default_tool_enabled,
can_write: record.can_write,
source_count,
chunk_count,
status: record.status.clone(),
metadata_json: record.metadata_json.clone(),
}
}
fn sync_lightrag_control_plane_knowledge_base_counts(
state: &AppState,
context: &RequestContext,
workspace_id: &str,
root_uri: &str,
sources: &KnowledgeRagSourceRegistry,
) -> Result<(), WebError> {
let records = state
.control_plane()
.list_knowledge_bases(
context.auth.actor_id.as_str(),
Some(workspace_id),
Some(root_uri),
)
.map_err(|error| control_plane_knowledge_base_error(error, context))?;
for record in records
.iter()
.filter(|record| is_legacy_lightrag_provider(&record.provider))
{
let (source_count, chunk_count) =
knowledge_base_source_counts(sources, &record.provider_kb_id);
if record.source_count == source_count && record.chunk_count == chunk_count {
continue;
}
state
.control_plane()
.upsert_knowledge_base(control_plane_knowledge_base_upsert_input(
record,
source_count,
chunk_count,
))
.map_err(|error| control_plane_knowledge_base_error(error, context))?;
}
Ok(())
}
fn registry_owner_for_provider_kb(
sources: &KnowledgeRagSourceRegistry,
provider_kb_id: &str,
@@ -473,25 +647,26 @@ pub async fn status(
} else {
(None, Value::Array(Vec::new()))
};
let knowledge_bases = if active_knowledge_provider_kind() == KnowledgeProvider::Weknora {
if let Some(root_uri) = query.root_uri.as_deref().filter(|value| !value.is_empty()) {
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(query.workspace_id.as_deref(), root_uri);
let registry =
registry
.as_ref()
.cloned()
.unwrap_or_else(|| KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: workspace_id.clone(),
root_uri: root_uri.to_string(),
updated_at_ms: now_ms(),
indexed_roots: Vec::new(),
entries: Vec::new(),
});
let knowledge_bases = if let Some(root_uri) =
query.root_uri.as_deref().filter(|value| !value.is_empty())
{
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(query.workspace_id.as_deref(), root_uri);
let registry = registry
.as_ref()
.cloned()
.unwrap_or_else(|| KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: workspace_id.clone(),
root_uri: root_uri.to_string(),
updated_at_ms: now_ms(),
indexed_roots: Vec::new(),
entries: Vec::new(),
});
if active_knowledge_provider_kind() == KnowledgeProvider::Weknora {
let mut bases = read_knowledge_bases_registry(&root_path, &workspace_id, root_uri)?;
ensure_default_weknora_knowledge_bases(
&mut bases,
@@ -510,7 +685,19 @@ pub async fn status(
filter_knowledge_bases_for_actor(&mut visible_bases, &context.auth.actor_id);
Some(visible_bases)
} else {
None
sync_lightrag_control_plane_knowledge_base_counts(
&state,
&context,
&workspace_id,
root_uri,
&registry,
)?;
Some(control_plane_knowledge_bases_registry(
&state,
&context,
&workspace_id,
root_uri,
)?)
}
} else {
None
@@ -658,13 +845,6 @@ pub async fn create_knowledge_base(
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagCreateKnowledgeBaseRequest>,
) -> Result<Json<Value>, WebError> {
if active_knowledge_provider() != WEKNORA_PROVIDER {
return Err(WebError::bad_request_code(
"knowledge_rag_create_kb_provider_unsupported",
"当前只有 WeKnora provider 支持从 MNote 创建知识库",
)
.with_context(&context));
}
let name = body.name.trim();
if name.is_empty() {
return Err(WebError::bad_request_code(
@@ -681,6 +861,71 @@ pub async fn create_knowledge_base(
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut source_registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
if active_knowledge_provider_kind() != KnowledgeProvider::Weknora {
let now = now_ms();
let provider_kb_id = format!(
"lightrag-kb-{}",
short_hash(&format!(
"{}|{}|{}|{}",
context.auth.actor_id, workspace_id, body.root_uri, now
))
);
let (source_count, chunk_count) =
knowledge_base_source_counts(&source_registry, &provider_kb_id);
let record = state
.control_plane()
.upsert_knowledge_base(UpsertKnowledgeBaseInput {
id: None,
user_id: context.auth.actor_id.clone(),
workspace_id: Some(workspace_id.clone()),
root_uri: Some(body.root_uri.clone()),
name: name.to_string(),
description: body
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
provider: LEGACY_KNOWLEDGE_PROVIDER.to_string(),
provider_kb_id: provider_kb_id.clone(),
default_tool_enabled: body.default_tool_enabled.unwrap_or(true),
can_write: true,
source_count,
chunk_count,
status: "active".into(),
metadata_json: json!({
"source": "mnote.knowledge_rag",
"workspaceId": workspace_id.clone(),
"rootUri": body.root_uri.clone(),
})
.to_string(),
})
.map_err(|error| control_plane_knowledge_base_error(error, &context))?;
let bases = control_plane_knowledge_bases_registry(
&state,
&context,
&record.workspace_id.clone().unwrap_or_default(),
record.root_uri.as_deref().unwrap_or(body.root_uri.as_str()),
)?;
let base = Some(control_plane_knowledge_base_to_registry_base(&record));
return Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.create_knowledge_base_result.v1",
"provider": LEGACY_KNOWLEDGE_PROVIDER,
"providerConfig": knowledge_provider_config(),
"knowledgeBase": base,
"providerKnowledgeBaseId": provider_kb_id,
"knowledgeBases": bases,
"registry": {
"schema": source_registry.schema,
"workspaceId": source_registry.workspace_id,
"rootUri": source_registry.root_uri,
"sourceCount": source_registry.entries.len(),
},
})));
}
let mut bases = read_knowledge_bases_registry(&root_path, &workspace_id, &body.root_uri)?;
ensure_default_weknora_knowledge_bases(
&mut bases,
@@ -750,6 +995,95 @@ pub async fn create_knowledge_base(
})))
}
pub async fn delete_knowledge_base(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<KnowledgeRagDeleteKnowledgeBaseRequest>,
) -> Result<Json<Value>, WebError> {
let requested_kb_id = body
.provider_knowledge_base_id
.as_deref()
.or(body.knowledge_base_id.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"knowledge_rag_delete_kb_id_required",
"删除资料库缺少 knowledgeBaseId",
)
.with_context(&context)
})?;
if requested_kb_id == "lightrag-default" {
return Err(WebError::bad_request_code(
"knowledge_rag_delete_default_kb_forbidden",
"默认资料库不能删除",
)
.with_context(&context));
}
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state,
&context,
&body.root_uri,
)
.map_err(|error| error.with_context(&context))?;
let workspace_id = effective_workspace_id(body.workspace_id.as_deref(), &body.root_uri);
let mut registry = read_registry(&root_path, &workspace_id, &body.root_uri)?;
filter_source_registry_for_actor(&mut registry, &context.auth.actor_id);
let active_source_count = registry
.entries
.iter()
.filter(|entry| {
!entry.stale
&& entry.deleted_at_ms.is_none()
&& entry.provider_knowledge_base_id.as_deref() == Some(requested_kb_id)
})
.count();
if active_source_count > 0 {
return Err(WebError::new(
StatusCode::CONFLICT,
"knowledge_rag_delete_kb_not_empty",
"请先移除该资料库中的资料,再删除资料库",
)
.with_context(&context));
}
let bases =
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
let base = bases
.bases
.iter()
.find(|base| base.base_id == requested_kb_id || base.provider_kb_id == requested_kb_id)
.cloned()
.ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"knowledge_rag_delete_kb_not_found",
"资料库不存在,或当前用户无权删除",
)
.with_context(&context)
})?;
if !base.can_write {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"knowledge_rag_delete_kb_readonly",
"当前资料库不可删除",
)
.with_context(&context));
}
state
.control_plane()
.delete_knowledge_base(context.auth.actor_id.as_str(), &base.base_id)
.map_err(|error| control_plane_knowledge_base_error(error, &context))?;
let knowledge_bases =
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.knowledge_rag.delete_knowledge_base_result.v1",
"providerKnowledgeBaseId": base.provider_kb_id,
"knowledgeBaseId": base.base_id,
"knowledgeBases": knowledge_bases,
})))
}
pub(crate) async fn sync_registry_for_root(
state: &AppState,
context: &RequestContext,
@@ -808,6 +1142,17 @@ pub async fn ingest(
&context,
));
}
let lightrag_requested_provider_kb_id = requested_lightrag_knowledge_base_id(
body.knowledge_base_id.as_deref(),
body.provider_knowledge_base_id.as_deref(),
);
let lightrag_bases =
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
let provider_kb_id = scoped_lightrag_provider_kb_id_for_registry(
&lightrag_bases,
lightrag_requested_provider_kb_id.as_deref(),
&context,
)?;
let input_dir = lightrag_input_dir();
fs::create_dir_all(&input_dir).map_err(|error| {
WebError::bad_request_code(
@@ -903,6 +1248,8 @@ pub async fn ingest(
.iter()
.filter(|entry| {
entry.source_path == canonical_key
&& entry.provider_knowledge_base_id.as_deref()
== provider_kb_id.as_deref()
&& entry.deleted_at_ms.is_none()
&& !entry.stale
})
@@ -919,6 +1266,7 @@ pub async fn ingest(
if !force {
if let Some(existing) = registry.entries.iter().find(|entry| {
entry.source_path == canonical_key
&& entry.provider_knowledge_base_id.as_deref() == provider_kb_id.as_deref()
&& entry.source_hash == source_hash
&& entry.deleted_at_ms.is_none()
&& !entry.stale
@@ -937,6 +1285,7 @@ pub async fn ingest(
"lightRagFilePath": existing.light_rag_file_path,
"lightRagDocId": existing.light_rag_doc_id,
"lightRagStatus": existing.light_rag_status,
"providerKnowledgeBaseId": provider_kb_id,
"skipped": true,
"skipReason": "already_registered",
}));
@@ -955,7 +1304,11 @@ pub async fn ingest(
let now = now_ms();
let source_id = format!(
"lightrag-source-{}",
short_hash(&canonical.display().to_string())
short_hash(&format!(
"{}:{}",
canonical.display(),
provider_kb_id.as_deref().unwrap_or_default()
))
);
upsert_registry_entry(
&mut registry,
@@ -975,7 +1328,7 @@ pub async fn ingest(
provider_status: Some("submitted".into()),
provider_source_id: None,
provider_knowledge_id: None,
provider_knowledge_base_id: None,
provider_knowledge_base_id: provider_kb_id.clone(),
light_rag_doc_id: None,
light_rag_status: Some("submitted".into()),
light_rag_file_path: light_rag_file_path.clone(),
@@ -989,7 +1342,7 @@ pub async fn ingest(
&relative,
resolved.source_kind,
LEGACY_KNOWLEDGE_PROVIDER,
None,
provider_kb_id.as_deref(),
None,
)),
deleted_at_ms: None,
@@ -1009,11 +1362,19 @@ pub async fn ingest(
"parserHint": parser_hint,
"providerDelete": provider_delete,
"scanMode": if direct_scan_source { "direct" } else { "markdown_wrapper" },
"providerKnowledgeBaseId": provider_kb_id,
"lightRagStatus": "submitted",
}));
}
}
write_registry(&root_path, &mut registry)?;
sync_lightrag_control_plane_knowledge_base_counts(
&state,
&context,
&workspace_id,
&body.root_uri,
&registry,
)?;
let scan = lightrag_json(
reqwest::Method::POST,
@@ -1500,6 +1861,17 @@ pub async fn query_rag(
&context,
));
}
let requested_provider_kb_id = requested_lightrag_knowledge_base_id(
body.knowledge_base_id.as_deref(),
body.provider_knowledge_base_id.as_deref(),
);
let lightrag_bases =
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
let scoped_provider_kb_id = scoped_lightrag_provider_kb_id_for_registry(
&lightrag_bases,
requested_provider_kb_id.as_deref(),
&context,
)?;
let requested_mode = normalize_lightrag_query_mode(body.mode.as_deref());
let mode_decision =
resolve_lightrag_query_mode_for_scope(&registry, &source_scope, &requested_mode);
@@ -1523,6 +1895,7 @@ pub async fn query_rag(
let mut references =
mapped_references(&raw, &registry, &body.root_uri, &root_path, Some(&query));
filter_mapped_references_by_source_scope(&mut references, &source_scope);
filter_mapped_references_by_provider_kb_id(&mut references, scoped_provider_kb_id.as_deref());
let include_document_structure_index = body
.include_document_structure_index
.unwrap_or_else(|| mode_decision.reason == "source_scope_skip_kg_document");
@@ -1620,6 +1993,17 @@ pub async fn search(
&context,
));
}
let requested_provider_kb_id = requested_lightrag_knowledge_base_id(
body.knowledge_base_id.as_deref(),
body.provider_knowledge_base_id.as_deref(),
);
let lightrag_bases =
control_plane_knowledge_bases_registry(&state, &context, &workspace_id, &body.root_uri)?;
let scoped_provider_kb_id = scoped_lightrag_provider_kb_id_for_registry(
&lightrag_bases,
requested_provider_kb_id.as_deref(),
&context,
)?;
let requested_search_mode = normalize_knowledge_rag_search_mode(body.mode.as_deref());
let mode_decision = resolve_knowledge_rag_search_mode_for_scope(
&registry,
@@ -1663,6 +2047,7 @@ pub async fn search(
let mut references =
mapped_references(&raw, &registry, &body.root_uri, &root_path, Some(&query));
filter_mapped_references_by_source_scope(&mut references, &source_scope);
filter_mapped_references_by_provider_kb_id(&mut references, scoped_provider_kb_id.as_deref());
if search_mode == "exact" {
filter_mapped_references_by_search_query(&mut references, &query);
rank_mapped_references_for_query(&mut references, &query);
@@ -1988,6 +2373,43 @@ fn filter_mapped_references_by_source_scope(references: &mut Vec<Value>, source_
});
}
fn filter_mapped_references_by_provider_kb_id(
references: &mut Vec<Value>,
provider_kb_id: Option<&str>,
) {
let Some(provider_kb_id) = provider_kb_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
references.retain(|reference| {
mapped_reference_provider_kb_id(reference).as_deref() == Some(provider_kb_id)
});
}
fn mapped_reference_provider_kb_id(reference: &Value) -> Option<String> {
reference
.get("providerKnowledgeBaseId")
.or_else(|| reference.get("provider_knowledge_base_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
reference
.get("providerIds")
.and_then(|ids| {
ids.get("knowledgeBaseId")
.or_else(|| ids.get("knowledge_base_id"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn filter_mapped_references_by_search_query(references: &mut Vec<Value>, query: &str) {
let query_normalized = normalize_text_for_match(query).to_ascii_lowercase();
if query_normalized.is_empty() {
@@ -2695,6 +3117,55 @@ fn requested_weknora_knowledge_base_id(
.map(ToOwned::to_owned)
}
fn requested_lightrag_knowledge_base_id(
knowledge_base_id: Option<&str>,
provider_knowledge_base_id: Option<&str>,
) -> Option<String> {
provider_knowledge_base_id
.or(knowledge_base_id)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "lightrag-default")
.map(ToOwned::to_owned)
}
fn resolve_lightrag_provider_kb_id_for_registry(
bases: &KnowledgeRagKnowledgeBaseRegistry,
requested_kb_id: &str,
) -> Result<String, ()> {
let requested_kb_id = requested_kb_id.trim();
if requested_kb_id.is_empty() || requested_kb_id == "lightrag-default" {
return Err(());
}
bases
.bases
.iter()
.find(|base| {
is_legacy_lightrag_provider(&base.provider)
&& base.can_write
&& (base.provider_kb_id == requested_kb_id || base.base_id == requested_kb_id)
})
.map(|base| base.provider_kb_id.clone())
.ok_or(())
}
fn scoped_lightrag_provider_kb_id_for_registry(
bases: &KnowledgeRagKnowledgeBaseRegistry,
requested_provider_kb_id: Option<&str>,
context: &RequestContext,
) -> Result<Option<String>, WebError> {
requested_provider_kb_id
.map(|requested| resolve_lightrag_provider_kb_id_for_registry(bases, requested))
.transpose()
.map_err(|()| {
WebError::new(
StatusCode::FORBIDDEN,
"knowledge_rag_lightrag_kb_not_writable",
"当前资料库不存在,或无权写入",
)
.with_context(context)
})
}
#[cfg(test)]
fn scoped_weknora_knowledge_base_ids(requested_provider_kb_id: Option<&str>) -> Vec<String> {
if let Some(provider_kb_id) = requested_provider_kb_id
@@ -4187,6 +4658,9 @@ fn map_reference_plan(
let locator_degraded = entry.is_none_or(|entry| entry.stale || entry.deleted_at_ms.is_some());
let source_path = entry.map(|entry| entry.source_path.clone());
let source_root_relative_path = entry.map(|entry| entry.source_root_relative_path.clone());
let provider_kb_id = entry
.and_then(|entry| entry.provider_knowledge_base_id.as_deref())
.unwrap_or_default();
let primary_chunk = reference
.get("chunks")
.and_then(Value::as_array)
@@ -4298,6 +4772,14 @@ fn map_reference_plan(
"citationLabel": format!("[{}]", citation_id),
"matchSource": if occurrence_index.is_null() { "lightrag_reference" } else { "lightrag_search" },
"reference": reference,
"providerIds": {
"knowledgeBaseId": provider_kb_id,
"knowledgeId": doc_id.unwrap_or_default(),
"chunkId": chunk_id,
},
"providerKnowledgeBaseId": provider_kb_id,
"providerKnowledgeId": doc_id.unwrap_or_default(),
"providerChunkId": chunk_id,
"filePath": file_path,
"chunkId": chunk_id,
"sourceChunkId": source_chunk_id,
@@ -6907,13 +7389,18 @@ fn registry_entry_upsert_key_matches(
if existing.source_path != next.source_path {
return false;
}
let existing_provider = existing.provider.as_deref();
let next_provider = next.provider.as_deref();
if next_provider == Some(WEKNORA_PROVIDER)
&& next.provider_knowledge_base_id.as_deref().is_some()
if existing_provider.is_some() || next_provider.is_some() {
if existing_provider != next_provider {
return false;
}
}
if existing.provider_knowledge_base_id.as_deref().is_some()
|| next.provider_knowledge_base_id.as_deref().is_some()
{
return existing.provider.as_deref() == next_provider
&& existing.provider_knowledge_base_id.as_deref()
== next.provider_knowledge_base_id.as_deref();
return existing.provider_knowledge_base_id.as_deref()
== next.provider_knowledge_base_id.as_deref();
}
true
}
@@ -3128,6 +3128,8 @@ pub fn resolve_local_markdown_page_aggregate(
&parsed.body,
&attachment_paths,
);
let content =
infer_sibling_page_references_for_local_markdown(&canonical_root, &markdown_file.path, content);
let attachment_refs = parse_markdown_attachment_refs(
&parsed.body,
&markdown_file.path.display().to_string(),
@@ -3223,6 +3225,125 @@ pub fn resolve_local_markdown_page_aggregate(
})
}
fn infer_sibling_page_references_for_local_markdown(
root: &Path,
markdown_path: &Path,
content: Value,
) -> Value {
let Some(blocks) = content.as_array() else {
return content;
};
let sibling_pages = sibling_page_reference_targets(root, markdown_path);
if sibling_pages.is_empty() {
return content;
}
Value::Array(
blocks
.iter()
.map(|block| {
let Some(title) = plain_paragraph_page_title(block) else {
return block.clone();
};
let Some(source_path) = sibling_pages.get(&title) else {
return block.clone();
};
let block_id = block
.get("id")
.and_then(Value::as_str)
.unwrap_or("local-page-ref")
.to_string();
json!({
"id": block_id,
"type": "page_reference",
"props": {
"title": title,
"sourcePath": source_path,
},
"content": [{ "type": "text", "text": title, "styles": {} }],
"children": [],
})
})
.collect(),
)
}
fn sibling_page_reference_targets(_root: &Path, markdown_path: &Path) -> BTreeMap<String, String> {
let mut targets = BTreeMap::new();
let Some(parent_dir) = markdown_path.parent() else {
return targets;
};
let Ok(entries) = fs::read_dir(parent_dir) else {
return targets;
};
for entry in entries.flatten() {
let path = entry.path();
if path == markdown_path {
continue;
}
if path.is_dir() {
let Some(dir_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
let candidate = path.join(format!("{dir_name}.md"));
if candidate.is_file() {
if let Some(relative_path) = relative_path_from_base(parent_dir, &candidate) {
targets.insert(file_stem_title(dir_name), relative_path);
}
}
continue;
}
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if !is_markdown_file(file_name) {
continue;
}
if let Some(relative_path) = relative_path_from_base(parent_dir, &path) {
targets.insert(file_stem_title(file_name), relative_path);
}
}
targets
}
fn relative_path_from_base(base: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(base).ok()?;
Some(
relative
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/"),
)
.filter(|value| !value.is_empty())
}
fn plain_paragraph_page_title(block: &Value) -> Option<String> {
if block.get("type").and_then(Value::as_str) != Some("paragraph") {
return None;
}
let content = block.get("content").and_then(Value::as_array)?;
if content.len() != 1 {
return None;
}
let node = content.first()?;
if node.get("type").and_then(Value::as_str) != Some("text") {
return None;
}
if node
.get("styles")
.and_then(Value::as_object)
.map(|styles| !styles.is_empty())
.unwrap_or(false)
{
return None;
}
node.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn save_local_markdown_page(
root_uri: &str,
document_id: &str,
@@ -4094,7 +4215,8 @@ pub(crate) fn write_local_markdown_asset(
));
}
let asset_dir = markdown_dir.to_path_buf();
let asset_type = local_upload_asset_type(kind, &file.content_type);
let asset_dir = local_markdown_asset_dir(&canonical_root, &markdown_file.path, asset_type)?;
if !asset_dir.starts_with(&canonical_root) {
return Err(WebError::bad_request_code(
"local_folder_root_escape",
@@ -4143,7 +4265,6 @@ pub(crate) fn write_local_markdown_asset(
if let Some(ref mut value) = attachment_ref {
value.authorized = Some(true);
}
let asset_type = local_upload_asset_type(kind, &file.content_type);
let mut uploaded_assets = metadata.uploaded_assets;
uploaded_assets.insert(
root_relative_path.clone(),
@@ -7583,6 +7704,7 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
if matches!(
lower_name.as_str(),
".git"
| ".assets"
| ".mnote"
| ".codegraph"
| ".codex"
@@ -7613,7 +7735,8 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
{
return true;
}
relative_path == ".mnote/trash"
lower_name.ends_with(".assets")
|| relative_path == ".mnote/trash"
|| relative_path.starts_with(".mnote/trash/")
|| is_local_index_artifact_entry(relative_path)
|| is_local_ocr_intermediate_entry(relative_path, file_name)
@@ -8817,10 +8940,62 @@ fn markdown_href_for_relative_path(relative_path: &str) -> String {
}
}
fn local_markdown_asset_dir(
root: &Path,
markdown_path: &Path,
asset_type: &str,
) -> Result<PathBuf, WebError> {
let markdown_dir = markdown_path.parent().ok_or_else(|| {
WebError::bad_request_code(
"local_asset_upload_bad_markdown_path",
"本地 Markdown 文件路径无父目录",
)
})?;
if !markdown_dir.starts_with(root) {
return Err(WebError::bad_request_code(
"local_folder_root_escape",
"本地资源目录不能越过 root",
));
}
let stem = markdown_path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("Page")
.trim();
let parent_name = markdown_dir
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("")
.trim();
let asset_root = if !stem.is_empty() && stem == parent_name {
markdown_dir.join(".assets")
} else {
markdown_dir.join(format!(
"{}.assets",
if stem.is_empty() { "Page" } else { stem }
))
};
Ok(asset_root.join(local_markdown_asset_bucket(asset_type)))
}
fn local_markdown_asset_bucket(asset_type: &str) -> &'static str {
match asset_type.trim().to_ascii_lowercase().as_str() {
"image" => "image",
"audio" => "audio",
"video" => "video",
_ => "file",
}
}
fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str {
let normalized_kind = kind.trim().to_ascii_lowercase();
if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") {
let normalized_mime = mime_type.trim().to_ascii_lowercase();
if normalized_kind == "image" || normalized_mime.starts_with("image/") {
"image"
} else if normalized_kind == "audio" || normalized_mime.starts_with("audio/") {
"audio"
} else if normalized_kind == "video" || normalized_mime.starts_with("video/") {
"video"
} else {
"file"
}
@@ -10560,6 +10735,53 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_page_aggregate_infers_sibling_page_blocks_from_plain_lines() {
let root = temp_root("mnote-local-sibling-page-lines");
init_workspace(&root);
std::fs::create_dir_all(root.join("完结项目").join("爱斯特完结项目"))
.expect("create child page dir");
std::fs::write(
root.join("完结项目").join("完结项目.md"),
"# 完结项目\n\n爱斯特完结项目\n\n普通文本\n",
)
.expect("write parent md");
std::fs::write(
root.join("完结项目")
.join("爱斯特完结项目")
.join("爱斯特完结项目.md"),
"# 爱斯特完结项目\n",
)
.expect("write child md");
let root_uri = format!("file://{}", root.display());
let document_id = format!(
"local-md:{}",
encode_local_id_segment("完结项目/完结项目.md")
);
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, &document_id)
.expect("aggregate");
let blocks = aggregate.body.content.as_array().expect("blocks");
assert_eq!(blocks[1]["type"], "page_reference");
assert_eq!(blocks[1]["props"]["title"], "爱斯特完结项目");
assert_eq!(
blocks[1]["props"]["sourcePath"],
"爱斯特完结项目/爱斯特完结项目.md"
);
assert_eq!(blocks[2]["type"], "paragraph");
assert_eq!(
aggregate.body.block_document["blocks"][1]["type"],
"page_reference"
);
assert_eq!(
aggregate.body.block_document["blocks"][1]["attrs"]["sourcePath"],
"爱斯特完结项目/爱斯特完结项目.md"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_does_not_migrate_runtime_open_url_inline_link() {
let root = temp_root("mnote-local-uploaded-md-inline-link");
@@ -13994,9 +14216,22 @@ fn main() {}
"# Assets\n",
)
.expect("write md");
std::fs::create_dir_all(root.join("docs").join("README")).expect("create page dir");
std::fs::write(root.join("docs").join("README").join("photo.png"), b"old")
.expect("write existing asset");
std::fs::create_dir_all(
root.join("docs")
.join("README")
.join(".assets")
.join("image"),
)
.expect("create image asset dir");
std::fs::write(
root.join("docs")
.join("README")
.join(".assets")
.join("image")
.join("photo.png"),
b"old",
)
.expect("write existing asset");
let root_uri = format!("file://{}", root.display());
let asset = write_local_markdown_asset(
@@ -14011,15 +14246,21 @@ fn main() {}
)
.expect("upload asset");
assert_eq!(asset["sourcePath"], "photo-1.png");
assert_eq!(asset["file_url"], "photo-1.png");
assert_eq!(asset["sourcePath"], ".assets/image/photo-1.png");
assert_eq!(asset["file_url"], ".assets/image/photo-1.png");
assert_eq!(asset["asset_type"], "image");
assert_eq!(asset["sourceKind"], "local_folder");
assert_eq!(asset["uploadIntent"], "editor.markdown.attach");
assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png");
assert_eq!(asset["markdownRelativePath"], "photo-1.png");
assert_eq!(asset["markdownHref"], "./photo-1.png");
assert_eq!(asset["attachmentRef"]["rawHref"], "./photo-1.png");
assert_eq!(
asset["rootRelativePath"],
"docs/README/.assets/image/photo-1.png"
);
assert_eq!(asset["markdownRelativePath"], ".assets/image/photo-1.png");
assert_eq!(asset["markdownHref"], "./.assets/image/photo-1.png");
assert_eq!(
asset["attachmentRef"]["rawHref"],
"./.assets/image/photo-1.png"
);
assert_eq!(asset["attachmentRef"]["kind"], "pageLocal");
assert_eq!(asset["attachmentRef"]["openKind"], "image");
assert_eq!(asset["attachmentRef"]["authorized"], true);
@@ -14028,8 +14269,14 @@ fn main() {}
"local-md:docs~2FREADME~2FREADME.md"
);
assert_eq!(
std::fs::read(root.join("docs").join("README").join("photo-1.png"))
.expect("read copied asset"),
std::fs::read(
root.join("docs")
.join("README")
.join(".assets")
.join("image")
.join("photo-1.png"),
)
.expect("read copied asset"),
b"new-image"
);
let markdown_asset = write_local_markdown_asset(
@@ -14043,11 +14290,17 @@ fn main() {}
},
)
.expect("upload markdown asset");
assert_eq!(markdown_asset["sourcePath"], "notes.md");
assert_eq!(markdown_asset["sourcePath"], ".assets/file/notes.md");
assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach");
assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md");
assert_eq!(markdown_asset["markdownRelativePath"], "notes.md");
assert_eq!(markdown_asset["markdownHref"], "./notes.md");
assert_eq!(
markdown_asset["rootRelativePath"],
"docs/README/.assets/file/notes.md"
);
assert_eq!(
markdown_asset["markdownRelativePath"],
".assets/file/notes.md"
);
assert_eq!(markdown_asset["markdownHref"], "./.assets/file/notes.md");
assert_eq!(markdown_asset["attachmentRef"]["openKind"], "text");
assert_eq!(
markdown_asset["ownerDocumentId"],
@@ -14071,16 +14324,16 @@ fn main() {}
);
assert_eq!(
windows_path_asset["sourcePath"],
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
".assets/file/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
);
assert_eq!(
windows_path_asset["rootRelativePath"],
"docs/README/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
"docs/README/.assets/file/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
);
let uploaded_asset_index =
std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json"))
.expect("uploaded asset index");
assert!(uploaded_asset_index.contains("docs/README/notes.md"));
assert!(uploaded_asset_index.contains("docs/README/.assets/file/notes.md"));
std::fs::write(root.join("docs").join("Loose.md"), "# Loose\n").expect("write loose md");
let loose_asset = write_local_markdown_asset(
@@ -14094,45 +14347,47 @@ fn main() {}
},
)
.expect("upload loose markdown asset");
assert_eq!(loose_asset["sourcePath"], "loose.pdf");
assert_eq!(loose_asset["rootRelativePath"], "docs/loose.pdf");
assert_eq!(loose_asset["markdownRelativePath"], "loose.pdf");
assert_eq!(loose_asset["markdownHref"], "./loose.pdf");
assert_eq!(loose_asset["sourcePath"], "Loose.assets/file/loose.pdf");
assert_eq!(
loose_asset["rootRelativePath"],
"docs/Loose.assets/file/loose.pdf"
);
assert_eq!(
loose_asset["markdownRelativePath"],
"Loose.assets/file/loose.pdf"
);
assert_eq!(loose_asset["markdownHref"], "./Loose.assets/file/loose.pdf");
assert!(
root.join("docs").join("loose.pdf").is_file(),
"非 bundle Markdown 上传应写入 md 同目录"
root.join("docs")
.join("Loose.assets")
.join("file")
.join("loose.pdf")
.is_file(),
"非 bundle Markdown 上传应写入页面专属 assets 目录"
);
assert!(
!root.join("docs").join("Loose").join("loose.pdf").exists(),
"非 bundle Markdown 上传不应写入同名子目录"
!root.join("docs").join("loose.pdf").exists(),
"非 bundle Markdown 上传不应写入 md 同目录"
);
let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README")
.expect("file tree");
let items = snapshot.projection["items"].as_array().expect("items");
let notes_row = items
.iter()
.find(|item| item["title"].as_str() == Some("notes.md"))
.expect("uploaded markdown asset row");
assert_eq!(notes_row["rowKind"].as_str(), Some("asset"));
assert_eq!(notes_row["iconHint"].as_str(), Some("markdown"));
assert_eq!(
notes_row["resourceMeta"]["assetId"].as_str(),
Some("local-file:docs/README/notes.md")
assert!(
!items
.iter()
.any(|item| item["title"].as_str() == Some(".assets")),
"页面隐藏资源目录不应出现在 FileTree"
);
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
let page_items = page_tree.projection["items"]
.as_array()
.expect("page items");
let page_notes_row = page_items
.iter()
.find(|item| item["title"].as_str() == Some("notes.md"))
.expect("uploaded markdown asset page row");
assert_eq!(page_notes_row["rowKind"].as_str(), Some("asset"));
assert_eq!(page_notes_row["iconHint"].as_str(), Some("markdown"));
assert_eq!(
page_notes_row["resourceMeta"]["assetId"].as_str(),
Some("local-file:docs/README/notes.md")
assert!(
!page_items
.iter()
.any(|item| item["title"].as_str() == Some("notes.md")),
"页面隐藏资源目录内的 Markdown 附件不应进入 PageTree"
);
let _ = std::fs::remove_dir_all(&root);
@@ -52,6 +52,10 @@ enum MarkdownBlock {
name: String,
source_path: String,
},
PageReference {
title: String,
source_path: String,
},
}
#[derive(Debug, Clone)]
@@ -103,6 +107,7 @@ pub struct AttachmentRef {
pub relative_path: Option<String>,
pub ext: Option<String>,
pub content_type: Option<String>,
pub file_size: Option<u64>,
pub exists: Option<bool>,
pub authorized: Option<bool>,
pub open_kind: String,
@@ -346,6 +351,10 @@ fn build_attachment_ref(
.as_deref()
.and_then(attachment_content_type)
.map(str::to_string);
let file_size = resolved_absolute_path
.as_ref()
.and_then(|path| std::fs::metadata(path).ok())
.map(|metadata| metadata.len());
let exists = resolved_absolute_path
.as_ref()
.map(|path| Path::new(path).exists());
@@ -378,6 +387,7 @@ fn build_attachment_ref(
relative_path,
ext,
content_type,
file_size,
exists,
authorized: None,
open_kind,
@@ -672,6 +682,10 @@ fn append_ast_paragraph<'a>(
blocks.push(MarkdownBlock::Media { name, source_path });
return;
}
if let Some((title, source_path)) = paragraph_page_reference(node) {
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
}
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
@@ -696,6 +710,19 @@ fn append_ast_list_item<'a>(
NodeValue::Item(_) => (false, false),
_ => return append_ast_block(node, blocks, attachment_paths),
};
if !is_task {
let mut children = node.children();
if let Some(paragraph) = children.next() {
if children.next().is_none()
&& matches!(paragraph.data.borrow().value, NodeValue::Paragraph)
{
if let Some((title, source_path)) = paragraph_page_reference(paragraph) {
blocks.push(MarkdownBlock::PageReference { title, source_path });
return;
}
}
}
}
let mut content = Vec::new();
for child in node.children() {
match child.data.borrow().value.clone() {
@@ -849,6 +876,23 @@ fn paragraph_attachment_media<'a>(
link_attachment_media(first, attachment_paths)
}
fn paragraph_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_page_reference(first).or_else(|| {
let inline = collect_inline_children(node);
if inline.len() != 1 {
return None;
}
let item = inline.first()?;
let href = item.styles.link.as_deref()?;
page_reference_from_target(href, &item.text)
})
}
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
@@ -929,6 +973,67 @@ fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
))
}
fn link_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
page_reference_from_target(link.url.trim(), &collect_plain_text(node))
}
fn page_reference_from_target(target: &str, label: &str) -> Option<(String, String)> {
let target = normalized_markdown_link_target(target)?;
if target.is_empty()
|| target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with('#')
|| target.starts_with("mailto:")
|| target.starts_with("file:")
{
return None;
}
let path_part = target
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(target)
.split_once('?')
.map(|(path, _)| path)
.unwrap_or(target);
let extension = std::path::Path::new(path_part)
.extension()
.and_then(|value| value.to_str())?;
if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") {
return None;
}
let fallback_title = std::path::Path::new(path_part)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("页面")
.trim();
let title = label.trim();
Some((
if title.is_empty() {
fallback_title.to_string()
} else {
title.to_string()
},
path_part.to_string(),
))
}
fn normalized_markdown_link_target(target: &str) -> Option<&str> {
let trimmed = target.trim();
if trimmed.is_empty() {
return None;
}
if let Some(value) = trimmed
.strip_prefix('<')
.and_then(|value| value.strip_suffix('>'))
{
return Some(value.trim());
}
Some(trimmed)
}
fn is_local_mindmap_file_name(file_name: &str) -> bool {
let trimmed = file_name.trim();
let lower = trimmed.to_ascii_lowercase();
@@ -1035,6 +1140,16 @@ fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value {
"content": [],
"children": [],
}),
MarkdownBlock::PageReference { title, source_path } => json!({
"id": format!("local-block-{block_number}"),
"type": "page_reference",
"props": {
"title": title,
"sourcePath": source_path,
},
"content": [{ "type": "text", "text": title, "styles": {} }],
"children": [],
}),
}
}
@@ -1267,6 +1382,35 @@ mod tests {
);
}
#[test]
fn markdown_local_page_link_parses_as_page_reference_block() {
let blocks = markdown_to_blocks("[完结项目](<完结项目/完结项目.md> \"完结项目\")\n");
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("page_reference"));
assert_eq!(first["props"]["title"].as_str(), Some("完结项目"));
assert_eq!(
first["props"]["sourcePath"].as_str(),
Some("完结项目/完结项目.md")
);
}
#[test]
fn markdown_list_item_with_only_local_page_link_parses_as_page_reference_block() {
let blocks = markdown_to_blocks("* [知识](<知识/知识.md>)\n");
let first = blocks
.as_array()
.and_then(|items| items.first())
.expect("first block");
assert_eq!(first["type"].as_str(), Some("page_reference"));
assert_eq!(first["props"]["title"].as_str(), Some("知识"));
assert_eq!(first["props"]["sourcePath"].as_str(), Some("知识/知识.md"));
}
#[test]
fn markdown_attachment_refs_parse_standard_href_variants() {
let root = std::env::temp_dir().join(format!(
@@ -1305,6 +1449,7 @@ mod tests {
assert_eq!(refs[0].kind, "pageLocal");
assert_eq!(refs[0].open_kind, "pdf");
assert_eq!(refs[0].exists, Some(true));
assert_eq!(refs[0].file_size, Some(3));
assert_eq!(
refs[0].relative_path,
Some("docs/同目录 文件.pdf".to_string())
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/knowledge-rag/knowledge-bases",
post(knowledge_rag::create_knowledge_base),
post(knowledge_rag::create_knowledge_base).delete(knowledge_rag::delete_knowledge_base),
)
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
.route("/api/knowledge-rag/search", post(knowledge_rag::search))
@@ -4524,6 +4524,34 @@ mod tests {
);
}
#[tokio::test]
async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() {
let _guard = crate::test_support::hermes_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
let response = app()
.oneshot(
Request::builder()
.uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let js = String::from_utf8(body.to_vec()).expect("utf8");
assert!(
js.contains("inline0.js?devHot="),
"dev:hot 下 island 必须加载带 cache buster 的页面块导航 bridge"
);
}
#[tokio::test]
async fn leptos_tiptap_lazy_mindmap_runtime_asset_is_served() {
let response = app()
@@ -5495,6 +5523,9 @@ mod tests {
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("navigateLocalMarkdownDeletedFallback"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("localFolderNavigationFallbackUrl"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("window.location.assign"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
+16 -3
View File
@@ -288,6 +288,14 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab"));
assert!(SIDEBAR_TREE_RUNTIME_JS
.contains("applySearchSwitchState(overlay, { knowledge: false"));
assert!(!SIDEBAR_TREE_RUNTIME_JS
.contains("applySearchSwitchState(overlay, { knowledge: true"));
assert!(SIDEBAR_TREE_RUNTIME_JS
.contains("data-search-switch=\"knowledge\" role=\"switch\" aria-checked=\"false\""));
assert!(!SIDEBAR_TREE_RUNTIME_JS
.contains("data-search-switch=\"knowledge\" role=\"switch\" aria-checked=\"true\""));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("treeView"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree:local-command"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
@@ -566,7 +574,7 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-action=\"prune\""));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-source-filters"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("filter-sources"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("Provider 未映射"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("未关联资料"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("Rerank"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("scheduleKnowledgeRagStatusBridge"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagStatusHasInFlight"));
@@ -593,7 +601,12 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localOcrAutoEnabled"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-mnote-local-ocr-auto-retired"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.href = '/knowledge'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function knowledgeHostUrl"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("knowledgeUrl.searchParams.set('workspaceId'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("knowledgeUrl.searchParams.set('sourceKind'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("knowledgeUrl.searchParams.set('rootUri'"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("window.location.href = '/knowledge'"));
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("var knowledgeUrl = '/knowledge'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("open-knowledge-rag-debug-settings"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/debug/knowledge-rag"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-knowledge-rag-action]"));
@@ -607,7 +620,7 @@ mod tests {
SIDEBAR_TREE_RUNTIME_JS.contains(r#"<option value="exact" selected>关键词</option>"#)
);
assert!(SIDEBAR_TREE_RUNTIME_JS
.contains(r#"data-search-switch="knowledge" role="switch" aria-checked="true""#));
.contains(r#"data-search-switch="knowledge" role="switch" aria-checked="false""#));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("knowledgeRagSourceRelativePath"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("supportsKnowledgeRagSource"));
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("runLocalOcr"));
+10 -2
View File
@@ -154,7 +154,11 @@ a:hover {
.material-symbols-outlined[data-icon="star"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 3 2.7 5.4 6 .9-4.4 4.3 1 6-5.3-2.8-5.3 2.8 1-6-4.4-4.3 6-.9z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="history"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 12a8 8 0 1 0 2.3-5.7L4 8.5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M4 4v4.5h4.5M12 8v5l3.5 2' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="auto_awesome"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 3 1.6 5.2L19 10l-5.4 1.8L12 17l-1.6-5.2L5 10l5.4-1.8zM5.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8zM18.5 2.5l.7 1.8 1.8.7-1.8.7-.7 1.8-.7-1.8-1.8-.7 1.8-.7z' fill='black'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="refresh"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 8.5A6.5 6.5 0 0 1 18.5 6M16.5 15.5A6.5 6.5 0 0 1 5.5 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="folder"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.5 7.5h6l2 2H20.5v8.5a2 2 0 0 1-2 2H5.5a2 2 0 0 1-2-2z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="folder_open"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.5 7.5h6l2 2H21v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a1.5 1.5 0 0 1 1.5-1.5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M4 18 7 11h14l-3 7' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="folder_add"],
.material-symbols-outlined[data-icon="create_new_folder"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.5 7.5h6l2 2H20.5v8.5a2 2 0 0 1-2 2H5.5a2 2 0 0 1-2-2z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M14.5 12.5v5M12 15h5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="drive_file_move"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 6h6l2 2h8v10H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m13 12 3 3-3 3M8 15h8' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="delete"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 6h14M9 6V4h6v2M8 6l1 14h6l1-14M10.5 10v6M13.5 10v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="right_panel_open"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v14H4zM14 5v14M8 12h6M11 9l3 3-3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
@@ -168,6 +172,12 @@ a:hover {
.material-symbols-outlined[data-icon="arrow_outward"],
.material-symbols-outlined[data-icon="open_in_new"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 6H5v13h13v-3M12 5h7v7M10 14 19 5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="share"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='18' cy='5' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='6' cy='12' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='19' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m8.7 10.7 6.6-4.4M8.7 13.3l6.6 4.4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="view_list"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 6h12M8 12h12M8 18h12' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='4.5' cy='6' r='1.5' fill='black'/%3E%3Ccircle cx='4.5' cy='12' r='1.5' fill='black'/%3E%3Ccircle cx='4.5' cy='18' r='1.5' fill='black'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="grid_view"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="upload_file"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 20h12a2 2 0 0 0 2-2V9l-5-5H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M14 4v6h6M12 16V9M9 12l3-3 3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="note_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 9h6M9 13h3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M16 13v5M13.5 15.5h5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="check_circle"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m8 12 2.6 2.6L16.5 9' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="hub"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='6' cy='12' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='6' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='18' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M8.7 10.7 15.3 7.3M8.7 13.3l6.6 3.4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="chat"],
@@ -219,7 +229,6 @@ a:hover {
.material-symbols-outlined[data-icon="text_fields"]::before,
.material-symbols-outlined[data-icon="toc"]::before,
.material-symbols-outlined[data-icon="translate"]::before,
.material-symbols-outlined[data-icon="upload_file"]::before,
.material-symbols-outlined[data-icon="view_column"]::before {
width: auto;
height: auto;
@@ -255,7 +264,6 @@ a:hover {
.material-symbols-outlined[data-icon="text_fields"]::before { content: "T"; }
.material-symbols-outlined[data-icon="toc"]::before { content: "☰"; }
.material-symbols-outlined[data-icon="translate"]::before { content: "文"; }
.material-symbols-outlined[data-icon="upload_file"]::before { content: "⇧"; }
.material-symbols-outlined[data-icon="view_column"]::before { content: "▥"; }
.material-symbols-outlined[data-icon="radio_button_unchecked"]::before {
width: .78em;
@@ -1232,6 +1232,36 @@
background: var(--wolai-bg-sidebar);
}
.mnote-navigation-page__item.mnote-page-block-link {
display: inline-flex;
width: fit-content;
max-width: 100%;
gap: 7px;
min-height: 28px;
padding: 1px 3px;
border-radius: 4px;
color: #37352f;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgba(55, 53, 47, 0.42);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.mnote-navigation-page__item.mnote-page-block-link::before {
content: "";
width: 16px;
height: 16px;
flex: 0 0 auto;
background: center / 16px 16px no-repeat url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.25 2.25h5.25L12.75 5.5v8.25h-8.5V2.25Z' stroke='%2337352f' stroke-width='1.25' stroke-linejoin='round'/%3E%3Cpath d='M9.5 2.25V5.5h3.25' stroke='%2337352f' stroke-width='1.25' stroke-linejoin='round'/%3E%3Cpath d='M6.25 8h4M6.25 10.25h3' stroke='%2337352f' stroke-width='1.1' stroke-linecap='round'/%3E%3C/svg%3E");
opacity: 0.86;
}
.mnote-navigation-page__item.mnote-page-block-link:hover {
background: rgba(55, 53, 47, 0.08);
text-decoration-color: rgba(55, 53, 47, 0.68);
}
.document-main-editor-group {
min-width: 0;
min-height: 0;
@@ -2541,8 +2571,8 @@
}
.mnote-knowledge-rag-settings-panel {
width: min(1120px, calc(100vw - 24px));
max-height: calc(100vh - 72px);
width: min(1180px, calc(100vw - 24px));
max-height: calc(100vh - 32px);
overflow: hidden;
}
@@ -2558,7 +2588,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
padding: 10px 16px;
border-bottom: 1px solid #E5E7EB;
background: #FFFFFF;
}
@@ -2566,11 +2596,13 @@
.mnote-weknora-kb-page-title {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
flex-direction: row;
gap: 10px;
align-items: baseline;
}
.mnote-weknora-kb-page-eyebrow {
display: none;
color: #2F7D4A;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
letter-spacing: 0;
@@ -2579,8 +2611,8 @@
.mnote-weknora-kb-page-title strong {
color: #111827;
font-size: 20px;
line-height: 28px;
font-size: 17px;
line-height: 22px;
}
.mnote-weknora-kb-page-title span:last-child {
@@ -2614,19 +2646,23 @@
}
.mnote-weknora-kb-page-status {
padding: 10px 18px;
padding: 6px 16px;
border-bottom: 1px solid #E5E7EB;
background: #F9FAFB;
color: #4B5563;
background: #F7FBF8;
color: #166534;
font-size: 12px;
line-height: 18px;
}
.mnote-weknora-kb-page-body {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
min-height: min(620px, calc(100vh - 190px));
max-height: calc(100vh - 150px);
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
min-height: min(710px, calc(100vh - 122px));
max-height: calc(100vh - 96px);
}
.mnote-weknora-kb-page-body[data-kb-list-hidden="true"] {
grid-template-columns: minmax(0, 1fr);
}
.mnote-weknora-kb-list-pane,
@@ -2644,6 +2680,10 @@
background: #FFFFFF;
}
.mnote-weknora-kb-list-pane[hidden] {
display: none;
}
.mnote-weknora-kb-list-head {
display: flex;
align-items: center;
@@ -2675,6 +2715,7 @@
}
.mnote-weknora-kb-list-head button,
.mnote-knowledge-rag-kb-manager button,
.mnote-weknora-doc-toolbar button,
.mnote-weknora-doc-actions button {
display: inline-flex;
@@ -2698,12 +2739,33 @@
}
.mnote-weknora-kb-list-head button:hover,
.mnote-knowledge-rag-kb-manager button:hover:not(:disabled),
.mnote-weknora-doc-toolbar button:hover,
.mnote-weknora-doc-actions button:hover {
background: #F3F4F6;
color: #111827;
}
.mnote-knowledge-rag-kb-manager button:disabled,
.mnote-weknora-doc-toolbar button:disabled,
.mnote-weknora-doc-actions button:disabled {
opacity: .48;
cursor: not-allowed;
}
.mnote-weknora-doc-toolbar button.mnote-knowledge-rag-primary-action,
.mnote-weknora-doc-actions button.mnote-knowledge-rag-primary-action {
border-color: #2F7D4A;
background: #2F7D4A;
color: #FFFFFF;
}
.mnote-weknora-doc-toolbar button.mnote-knowledge-rag-primary-action:hover,
.mnote-weknora-doc-actions button.mnote-knowledge-rag-primary-action:hover {
background: #24683C;
color: #FFFFFF;
}
.mnote-weknora-kb-card-wrap {
display: flex;
flex-direction: column;
@@ -2713,7 +2775,7 @@
.mnote-weknora-kb-card {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 10px;
gap: 8px;
align-items: start;
width: 100%;
padding: 12px;
@@ -2820,6 +2882,10 @@
background: #F9FAFB;
}
.mnote-weknora-kb-create-card[hidden] {
display: none;
}
.mnote-weknora-kb-create-title {
display: flex;
align-items: center;
@@ -2832,30 +2898,40 @@
.mnote-weknora-kb-detail-pane {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
gap: 10px;
overflow: hidden;
padding: 10px 14px 14px;
}
.mnote-weknora-kb-detail-hero {
order: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(210px, 280px);
gap: 16px;
align-items: start;
padding: 16px;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 8px 10px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-weknora-kb-detail-hero[hidden] {
display: none;
}
.mnote-weknora-kb-detail-hero select[hidden] {
display: none;
}
.mnote-weknora-kb-detail-hero h2 {
margin: 8px 0 4px;
margin: 0 0 2px;
color: #111827;
font-size: 22px;
line-height: 30px;
font-size: 16px;
line-height: 22px;
}
.mnote-weknora-kb-breadcrumb {
display: flex;
display: none;
align-items: center;
gap: 7px;
min-width: 0;
@@ -2880,7 +2956,59 @@
line-height: 18px;
}
.mnote-weknora-kb-type-pill {
display: none;
}
.mnote-knowledge-rag-kb-manager {
order: 1;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-knowledge-rag-kb-manager-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: end;
}
.mnote-knowledge-rag-kb-select-field {
display: flex;
flex: 1 1 300px;
min-width: 220px;
flex-direction: column;
gap: 4px;
color: #6B7280;
font-size: 11px;
line-height: 16px;
}
.mnote-knowledge-rag-kb-select-field select {
width: 100%;
height: 32px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
color: #111827;
font-size: 12px;
line-height: 18px;
padding: 0 10px;
}
.mnote-knowledge-rag-kb-manager-note {
color: #6B7280;
font-size: 11px;
line-height: 16px;
}
.mnote-knowledge-rag-detail-tabs {
order: 2;
display: inline-flex;
width: fit-content;
max-width: 100%;
@@ -2909,10 +3037,169 @@
font-weight: 600;
}
.mnote-knowledge-rag-detail-tab[hidden] {
display: none;
}
.mnote-knowledge-rag-detail-panel[hidden] {
display: none;
}
.mnote-knowledge-rag-detail-panel:not([hidden]) {
order: 3;
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.mnote-kb-search-form {
display: flex;
gap: 8px;
align-items: center;
padding: 10px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-kb-search-box {
display: flex;
min-width: 0;
flex: 1 1 auto;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 10px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #F9FAFB;
}
.mnote-kb-search-box .material-symbols-outlined {
flex: 0 0 auto;
color: #6B7280;
}
.mnote-kb-search-input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #111827;
font-size: 12px;
line-height: 18px;
}
.mnote-kb-search-results {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
margin-top: 10px;
overflow: auto;
}
.mnote-kb-search-empty {
display: grid;
min-height: 160px;
place-items: center;
border: 1px dashed #D1D5DB;
border-radius: 8px;
background: #F9FAFB;
color: #6B7280;
font-size: 12px;
line-height: 18px;
}
.mnote-kb-search-empty.is-error {
border-color: #FCA5A5;
background: #FEF2F2;
color: #B91C1C;
}
.mnote-kb-search-result-item {
display: flex;
flex-direction: column;
gap: 7px;
padding: 10px 12px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-kb-search-result-header,
.mnote-kb-search-result-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mnote-kb-search-result-index,
.mnote-kb-search-result-score {
display: inline-flex;
align-items: center;
min-height: 20px;
border-radius: 999px;
font-size: 11px;
line-height: 16px;
padding: 0 7px;
}
.mnote-kb-search-result-index {
background: #EEF2FF;
color: #3730A3;
font-weight: 600;
}
.mnote-kb-search-result-score {
background: #ECFDF5;
color: #166534;
}
.mnote-kb-search-result-source {
overflow: hidden;
color: #374151;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-kb-search-result-preview,
.mnote-kb-search-result-citation {
color: #4B5563;
font-size: 12px;
line-height: 18px;
}
.mnote-kb-search-result-citation {
padding-top: 6px;
border-top: 1px solid #F3F4F6;
color: #6B7280;
}
.mnote-kb-search-result-actions {
justify-content: flex-end;
}
.mnote-kb-search-result-actions button {
min-height: 26px;
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #FFFFFF;
color: #374151;
font-size: 11px;
line-height: 18px;
padding: 2px 8px;
}
.mnote-kb-search-result-actions button:hover:not(:disabled) {
background: #F3F4F6;
}
.mnote-weknora-doc-toolbar,
.mnote-weknora-doc-actions {
display: flex;
@@ -2922,10 +3209,11 @@
}
.mnote-weknora-doc-layout {
display: grid;
grid-template-columns: 184px minmax(0, 1fr);
gap: 12px;
align-items: start;
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
}
.mnote-weknora-doc-sidebar {
@@ -2939,6 +3227,10 @@
background: #FFFFFF;
}
.mnote-weknora-doc-sidebar[hidden] {
display: none;
}
.mnote-weknora-doc-sidebar-head {
display: flex;
align-items: baseline;
@@ -2997,11 +3289,21 @@
.mnote-weknora-doc-main {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
flex-direction: column;
gap: 10px;
}
.mnote-weknora-doc-toolbar {
position: sticky;
top: 0;
z-index: 1;
padding-bottom: 0;
background: #F6F7F9;
}
.mnote-weknora-view-switch {
display: inline-flex;
align-items: center;
@@ -3050,7 +3352,9 @@
}
.mnote-weknora-doc-table {
overflow: hidden;
flex: 1 1 auto;
min-height: 440px;
overflow: auto;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
@@ -3065,13 +3369,13 @@
.mnote-weknora-doc-table::before {
display: grid;
grid-template-columns: minmax(0, 1fr) 104px 104px 124px;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid #E5E7EB;
background: #F9FAFB;
color: #6B7280;
content: "文档 状态 Chunk 操作";
content: "资料 操作";
font-size: 11px;
line-height: 16px;
white-space: pre;
@@ -3112,6 +3416,44 @@
gap: 8px;
}
.mnote-knowledge-rag-diagnostics {
order: 4;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-knowledge-rag-diagnostics summary {
cursor: pointer;
color: #4B5563;
font-size: 12px;
line-height: 18px;
list-style: none;
padding: 8px 10px;
}
.mnote-knowledge-rag-diagnostics summary::-webkit-details-marker {
display: none;
}
.mnote-knowledge-rag-diagnostics summary::after {
content: "展开";
float: right;
color: #9CA3AF;
}
.mnote-knowledge-rag-diagnostics[open] summary {
border-bottom: 1px solid #E5E7EB;
}
.mnote-knowledge-rag-diagnostics[open] summary::after {
content: "收起";
}
.mnote-knowledge-rag-diagnostics .mnote-knowledge-rag-meta {
padding: 10px;
}
.mnote-knowledge-rag-meta div {
display: flex;
min-width: 0;
@@ -3169,6 +3511,20 @@
gap: 6px;
}
.mnote-knowledge-rag-source-composer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-knowledge-rag-source-composer[hidden] {
display: none;
}
.mnote-knowledge-rag-source-tools {
display: flex;
flex-wrap: wrap;
@@ -3182,17 +3538,17 @@
.mnote-knowledge-rag-source-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 7px;
}
.mnote-knowledge-rag-source-filters button {
border: 1px solid #E3E1DE;
border-radius: 6px;
border: 1px solid #E5E7EB;
border-radius: 999px;
background: #FFFFFF;
color: #5F5A54;
color: #4B5563;
font-size: 11px;
line-height: 18px;
padding: 2px 7px;
padding: 3px 9px;
white-space: nowrap;
}
@@ -3225,7 +3581,7 @@
}
.mnote-knowledge-rag-source-input-row {
grid-template-columns: minmax(0, 1fr) 56px 28px;
grid-template-columns: minmax(0, 1fr) 56px 30px;
}
.mnote-knowledge-rag-input-status {
@@ -3275,7 +3631,7 @@
}
.mnote-knowledge-rag-source-main .mnote-weknora-source-aux {
display: flex;
display: none;
flex-wrap: wrap;
gap: 6px;
font-family: inherit;
@@ -67,8 +67,8 @@ export interface InitOutput {
readonly intounderlyingsource_cancel: (a: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45: (a: number, b: number) => void;
@@ -33,11 +33,12 @@ import { register_text_style } from './snippets/leptos-tiptap-c355e6ec24c3df4c/s
import { register_toc_node } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_toc_node.js';
import { register_underline } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js';
import { register_youtube } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js';
import { find_mnote_block_anchor, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js';
import { download_mnote_image_url, find_mnote_block_anchor, open_mnote_page_block_href, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js';
import * as import1 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import2 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import3 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import3 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import4 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import5 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
export class IntoUnderlyingByteSource {
@@ -314,8 +315,13 @@ function __wbg_get_imports() {
const ret = arg0.anchorNode;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_assign_d4fed0f8abb71719: function() { return handleError(function (arg0, arg1, arg2) {
arg0.assign(getStringFromWasm0(arg1, arg2));
__wbg_appendChild_e95c8b3b936d250a: function() { return handleError(function (arg0, arg1) {
const ret = arg0.appendChild(arg1);
return ret;
}, arguments); },
__wbg_attachShadow_a3865128b06993cf: function() { return handleError(function (arg0, arg1) {
const ret = arg0.attachShadow(arg1);
return ret;
}, arguments); },
__wbg_blur_583010b6b4026c5d: function() { return handleError(function (arg0) {
arg0.blur();
@@ -482,6 +488,10 @@ function __wbg_get_imports() {
const ret = arg0.done;
return ret;
},
__wbg_download_mnote_image_url_f3cfdff8b9b428d4: function(arg0, arg1, arg2, arg3) {
const ret = download_mnote_image_url(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
return ret;
},
__wbg_elementFromPoint_69f15053cea2ece4: function(arg0, arg1, arg2) {
const ret = arg0.elementFromPoint(arg1, arg2);
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -738,6 +748,10 @@ function __wbg_get_imports() {
const ret = arg0.item(arg1 >>> 0);
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_item_aa5667bdc8374f43: function(arg0, arg1) {
const ret = arg0.item(arg1 >>> 0);
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_iterator_013bc09ec998c2a7: function() {
const ret = Symbol.iterator;
return ret;
@@ -769,6 +783,10 @@ function __wbg_get_imports() {
const ret = arg0.length;
return ret;
},
__wbg_length_c157a50ce35f2f72: function(arg0) {
const ret = arg0.length;
return ret;
},
__wbg_localStorage_f5f66b1ffd2486bc: function() { return handleError(function (arg0) {
const ret = arg0.localStorage;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -847,6 +865,10 @@ function __wbg_get_imports() {
const ret = arg0.ok;
return ret;
},
__wbg_open_mnote_page_block_href_a512603dfc2e4bad: function(arg0, arg1) {
const ret = open_mnote_page_block_href(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbg_origin_1f038926109a2a37: function() { return handleError(function (arg0, arg1) {
const ret = arg1.origin;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
@@ -882,6 +904,10 @@ function __wbg_get_imports() {
__wbg_prototypesetcall_a6b02eb00b0f4ce2: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
},
__wbg_querySelectorAll_e9e3fbd41310476e: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.querySelectorAll(getStringFromWasm0(arg1, arg2));
return ret;
}, arguments); },
__wbg_querySelector_12b6c7cdf26a3483: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.querySelector(getStringFromWasm0(arg1, arg2));
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -1006,6 +1032,10 @@ function __wbg_get_imports() {
__wbg_removeAttribute_c75ac657c944b3f1: function() { return handleError(function (arg0, arg1, arg2) {
arg0.removeAttribute(getStringFromWasm0(arg1, arg2));
}, arguments); },
__wbg_removeChild_172df530ec85cc8a: function() { return handleError(function (arg0, arg1) {
const ret = arg0.removeChild(arg1);
return ret;
}, arguments); },
__wbg_removeEventListener_0069d57d090a1674: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3, arg4 !== 0);
}, arguments); },
@@ -1113,6 +1143,9 @@ function __wbg_get_imports() {
__wbg_set_method_c9f1f985f6b6c427: function(arg0, arg1, arg2) {
arg0.method = getStringFromWasm0(arg1, arg2);
},
__wbg_set_mode_43ec0c73d11c9dba: function(arg0, arg1) {
arg0.mode = __wbindgen_enum_ShadowRootMode[arg1];
},
__wbg_set_mode_5e08d503428c06b9: function(arg0, arg1) {
arg0.mode = __wbindgen_enum_RequestMode[arg1];
},
@@ -1232,37 +1265,37 @@ function __wbg_get_imports() {
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1191, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 993, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1062, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1209, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1261, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1209, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 701, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 800, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1108, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1178, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1142, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5);
return ret;
},
@@ -1301,8 +1334,9 @@ function __wbg_get_imports() {
"./mnote-leptos-tiptap-spike-island_bg.js": import0,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import1,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import2,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import3,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import3,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import4,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import5,
};
}
@@ -1314,14 +1348,14 @@ function wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5(arg0, arg
wasm.wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3(arg0, arg1, arg2);
}
@@ -1346,6 +1380,9 @@ const __wbindgen_enum_ReadableStreamType = ["bytes"];
const __wbindgen_enum_RequestMode = ["same-origin", "no-cors", "cors", "navigate"];
const __wbindgen_enum_ShadowRootMode = ["open", "closed"];
const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr >>> 0, 1));
@@ -20,8 +20,8 @@ export const intounderlyingsource_pull: (a: number, b: any) => any;
export const intounderlyingsource_cancel: (a: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45: (a: number, b: number) => void;
@@ -74,6 +74,71 @@ export function download_mnote_selected_image() {
}
}
export function download_mnote_image_url(src, filenameHint) {
try {
if (!src) return false;
const url = new URL(String(src), window.location.href);
if (!(url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'data:')) return false;
const link = document.createElement('a');
link.href = url.href;
link.download = mnoteImageFilename({ getAttribute: (name) => name === 'title' || name === 'alt' ? String(filenameHint || '') : '' }, url.href);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
return true;
} catch (error) {
console.warn('mnote image preview download failed', error);
return false;
}
}
export function request_mnote_image_preview_fullscreen() {
try {
const target = document.querySelector('[data-testid="image-preview-stage"]')
|| document.querySelector('[data-testid="image-preview-dialog"]');
const request = target?.requestFullscreen || target?.webkitRequestFullscreen || target?.mozRequestFullScreen || target?.msRequestFullscreen;
if (!target || typeof request !== 'function') return false;
const result = request.call(target);
if (result?.catch) result.catch(() => {});
return true;
} catch (error) {
console.warn('mnote image preview fullscreen failed', error);
return false;
}
}
export function open_mnote_page_block_href(href) {
try {
const target = new URL(String(href || ''), window.location.href);
if (target.origin === window.location.origin && target.pathname.startsWith('/documents/')) {
const encodedId = target.pathname.slice('/documents/'.length).split('/')[0] || '';
const documentId = decodeURIComponent(encodedId);
const runtime = window.__mnoteDocumentPaneRuntime;
if (documentId && runtime && typeof runtime.openPrimaryDocument === 'function') {
const result = runtime.openPrimaryDocument({
documentId,
workspaceId: target.searchParams.get('workspaceId') || '',
sourceKind: target.searchParams.get('sourceKind') || '',
rootUri: target.searchParams.get('rootUri') || '',
url: target,
pageBlockNavigation: true,
});
if (result && typeof result.catch === 'function') {
result.catch((error) => {
console.warn('mnote page block pane navigation failed', error);
});
}
return true;
}
}
return false;
} catch (error) {
console.warn('mnote page block navigation failed', error);
return false;
}
}
export function bump_mnote_e27_ai_bridge_request_count() {
try {
const key = '__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__';
@@ -586,6 +586,12 @@ struct TreeCommandResult {
parent_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct WorkspaceSourceContext {
source_kind: Option<String>,
root_uri: Option<String>,
}
fn dispatch_tree_local_command(body: &Value, result: &Value) {
let Some(win) = window() else {
return;
@@ -607,15 +613,16 @@ async fn create_child_page_via_tree_command(
parent_id: String,
workspace_id: Option<String>,
title: String,
source_kind: Option<String>,
root_uri: Option<String>,
) -> Result<TreeCommandResult, String> {
let body = json!({
"action": "create",
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": "private",
"content": [],
});
let body = child_page_tree_command_body(
parent_id.clone(),
workspace_id,
title,
source_kind,
root_uri,
);
let body_string =
serde_json::to_string(&body).map_err(|err| format!("序列化创建子页面请求失败:{err}"))?;
@@ -706,17 +713,38 @@ fn block_plain_text_from_index(
Ok(normalized_page_block_title(&collect_plain_text(block)))
}
fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, title: &str) -> Value {
let href = if page_id.trim().is_empty() {
"#".to_string()
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
} else {
format!("/documents/{page_id}")
};
fn child_page_tree_command_body(
parent_id: String,
workspace_id: Option<String>,
title: String,
source_kind: Option<String>,
root_uri: Option<String>,
) -> Value {
let mut body = json!({
"action": "create",
"workspaceId": workspace_id,
"parentId": parent_id,
"title": title,
"accessScope": "private",
"content": [],
});
if let Some(source_kind) = trimmed_optional(source_kind) {
body["sourceKind"] = Value::String(source_kind);
}
if let Some(root_uri) = trimmed_optional(root_uri) {
body["rootUri"] = Value::String(root_uri);
}
body
}
fn page_reference_paragraph_node(
page_id: &str,
workspace_id: Option<&str>,
title: &str,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Value {
let href = page_reference_href(page_id, workspace_id, source_kind, root_uri);
paragraph_node(vec![json!({
"type": "text",
"text": normalized_page_block_title(title),
@@ -732,12 +760,139 @@ fn page_reference_paragraph_node(page_id: &str, workspace_id: Option<&str>, titl
})])
}
fn page_reference_href(
page_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> String {
if page_id.trim().is_empty() {
"#".to_string()
} else if has_local_source_context(source_kind, root_uri) {
let mut params = Vec::new();
if let Some(source_kind) = trimmed_str(source_kind) {
params.push(format!(
"sourceKind={}",
percent_encode_query_component(source_kind)
));
}
if let Some(root_uri) = trimmed_str(root_uri) {
params.push(format!(
"rootUri={}",
percent_encode_query_component(root_uri)
));
}
params.push("treeView=filetree".to_string());
format!("/documents/{page_id}?{}", params.join("&"))
} else if let Some(workspace_id) = workspace_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("/documents/{page_id}?workspaceId={workspace_id}")
} else {
format!("/documents/{page_id}")
}
}
fn current_workspace_source_context() -> WorkspaceSourceContext {
WorkspaceSourceContext {
source_kind: current_query_param_decoded("sourceKind")
.or_else(|| body_attribute("data-mnote-source-kind")),
root_uri: current_query_param_decoded("rootUri")
.or_else(|| body_attribute("data-mnote-root-uri")),
}
}
fn current_query_param_decoded(key: &str) -> Option<String> {
let search = window()?.location().search().ok()?;
query_param_from_search(&search, key).and_then(|value| percent_decode_query_component(&value))
}
fn query_param_from_search(search: &str, key: &str) -> Option<String> {
let query = search.trim_start_matches('?');
for pair in query.split('&') {
if pair.is_empty() {
continue;
}
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
if percent_decode_query_component(name).as_deref() == Some(key) {
return Some(value.to_string());
}
}
None
}
fn body_attribute(name: &str) -> Option<String> {
window()?
.document()?
.body()?
.get_attribute(name)
.and_then(|value| trimmed_optional(Some(value)))
}
fn has_local_source_context(source_kind: Option<&str>, root_uri: Option<&str>) -> bool {
trimmed_str(source_kind) == Some("local_folder") || trimmed_str(root_uri).is_some()
}
fn trimmed_optional(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn trimmed_str(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn percent_encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
let character = *byte as char;
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '~') {
encoded.push(character);
} else {
encoded.push('%');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
fn percent_decode_query_component(value: &str) -> Option<String> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' => {
if index + 2 >= bytes.len() {
return None;
}
let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok()?;
decoded.push(u8::from_str_radix(hex, 16).ok()?);
index += 3;
}
b'+' => {
decoded.push(b' ');
index += 1;
}
byte => {
decoded.push(byte);
index += 1;
}
}
}
String::from_utf8(decoded).ok()
}
fn replace_block_with_page_reference(
editor: TiptapEditorHandle,
block_index: usize,
page_id: &str,
workspace_id: Option<&str>,
title: &str,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<(), String> {
let mut document = editor
.get_json()
@@ -746,7 +901,8 @@ fn replace_block_with_page_reference(
if block_index >= content.len() {
return Err(format!("找不到第 {} 个块", block_index + 1));
}
content[block_index] = page_reference_paragraph_node(page_id, workspace_id, title);
content[block_index] =
page_reference_paragraph_node(page_id, workspace_id, title, source_kind, root_uri);
let next_payload = document
.get("content")
.and_then(Value::as_array)
@@ -787,12 +943,15 @@ pub(crate) fn run_block_turn_into_page_action(
return;
};
let workspace_id_value = workspace_id.get_untracked();
let source_context = current_workspace_source_context();
set_command_feedback.set(format!("正在创建子页面:{page_title}"));
spawn_local(async move {
match create_child_page_via_tree_command(
parent_document_id.clone(),
workspace_id_value.clone(),
page_title.clone(),
source_context.source_kind.clone(),
source_context.root_uri.clone(),
)
.await
{
@@ -812,6 +971,8 @@ pub(crate) fn run_block_turn_into_page_action(
child_page_id,
effective_workspace_id,
effective_title,
source_context.source_kind.as_deref(),
source_context.root_uri.as_deref(),
) {
Ok(()) => {
set_block_menu_open.set(false);
@@ -840,6 +1001,46 @@ pub(crate) fn run_block_turn_into_page_action(
});
}
#[cfg(test)]
mod tests {
use super::{child_page_tree_command_body, page_reference_paragraph_node};
#[test]
fn child_page_tree_command_body_preserves_local_source_context() {
let body = child_page_tree_command_body(
"local-md:Parent~2FParent.md".to_string(),
Some("local:_tmp_mnote".to_string()),
"子页面".to_string(),
Some("local_folder".to_string()),
Some("file:///tmp/mnote".to_string()),
);
assert_eq!(body["action"], "create");
assert_eq!(body["sourceKind"], "local_folder");
assert_eq!(body["rootUri"], "file:///tmp/mnote");
assert_eq!(body["parentId"], "local-md:Parent~2FParent.md");
assert_eq!(body["title"], "子页面");
}
#[test]
fn page_reference_node_for_local_folder_keeps_openable_source_params() {
let node = page_reference_paragraph_node(
"local-md:Parent~2FChild~2FChild.md",
Some("local:_tmp_mnote"),
"Child",
Some("local_folder"),
Some("file:///tmp/mnote"),
);
let href = node["content"][0]["marks"][0]["attrs"]["href"]
.as_str()
.expect("href");
assert!(href.starts_with("/documents/local-md:Parent~2FChild~2FChild.md?"));
assert!(href.contains("sourceKind=local_folder"));
assert!(href.contains("rootUri=file%3A%2F%2F%2Ftmp%2Fmnote"));
}
}
/// 将 delta operations 应用到 Tiptap JSON 树(原地修改)。
/// 返回 true 表示树发生了变更。
pub(crate) fn apply_block_delta_to_json(content: &mut Value, operations: &[Value]) -> bool {
@@ -1768,6 +1768,160 @@ pub(crate) const SPIKE_STYLE: &str = r#"
box-shadow: 0 0 0 5px rgba(255, 71, 71, 0.12);
}
.image-preview-dialog {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: rgba(0, 0, 0, 0.88);
color: #fff;
}
.image-preview-counter {
position: absolute;
top: 12px;
left: 14px;
font-size: 16px;
line-height: 1;
color: rgba(255, 255, 255, 0.9);
z-index: 2;
}
.image-preview-close {
position: absolute;
top: 4px;
right: 4px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: rgba(0, 0, 0, 0.86);
color: #fff;
font-size: 28px;
line-height: 1;
cursor: pointer;
z-index: 3;
}
.image-preview-close:hover,
.image-preview-close:focus-visible {
background: rgba(32, 32, 32, 0.96);
outline: none;
}
.image-preview-stage {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 34px 74px 70px;
box-sizing: border-box;
overflow: hidden;
cursor: grab;
user-select: none;
touch-action: none;
}
.image-preview-stage:active {
cursor: grabbing;
}
.image-preview-image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
background: #fff;
transform-origin: center center;
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.24);
user-select: none;
will-change: transform;
pointer-events: none;
}
.image-preview-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 46px;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 38px;
line-height: 1;
cursor: pointer;
z-index: 2;
}
.image-preview-nav-prev {
left: 0;
}
.image-preview-nav-next {
right: 0;
}
.image-preview-nav:hover:not(:disabled),
.image-preview-nav:focus-visible:not(:disabled) {
background: rgba(0, 0, 0, 0.74);
outline: none;
}
.image-preview-nav:disabled {
opacity: 0.36;
cursor: not-allowed;
}
.image-preview-toolbar {
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 2px;
padding: 6px 9px;
border-radius: 4px;
background: rgba(112, 112, 112, 0.9);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
z-index: 3;
}
.image-preview-tool {
min-width: 32px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 3px;
background: transparent;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.image-preview-tool-wide {
min-width: 46px;
}
.image-preview-tool:hover,
.image-preview-tool:focus-visible {
background: rgba(255, 255, 255, 0.18);
outline: none;
}
.editor-surface .ProseMirror .tiptap-table-of-contents-node {
display: block;
margin: 12px 0;
@@ -2255,42 +2409,44 @@ pub(crate) const SPIKE_STYLE: &str = r#"
}
.editor-surface .ProseMirror p:has(> a.mnote-page-block-link) {
width: 100%;
margin: 0 0 8px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 71, 71, 0.12);
margin: 1px 0 4px;
padding: 0;
background: transparent;
}
.editor-surface .ProseMirror a.mnote-page-block-link {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 7px;
max-width: 100%;
padding: 1px 4px;
min-height: 28px;
padding: 1px 3px;
border-radius: 4px;
color: #37352f;
font-weight: 600;
line-height: 1.45;
text-decoration: none;
text-decoration: underline;
text-decoration-color: rgba(55, 53, 47, 0.42);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
cursor: pointer;
}
.editor-surface .ProseMirror a.mnote-page-block-link::before {
content: "";
width: 14px;
height: 14px;
width: 16px;
height: 16px;
flex: 0 0 auto;
border: 1.4px solid #c8c5c0;
border-radius: 3px;
background: linear-gradient(180deg, #ffffff 0%, #f6f4f1 100%);
box-shadow: inset 0 -2px 0 rgba(55, 53, 47, 0.08);
border: 0;
border-radius: 0;
background: center / 16px 16px no-repeat url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.25 2.25h5.25L12.75 5.5v8.25h-8.5V2.25Z' stroke='%2337352f' stroke-width='1.25' stroke-linejoin='round'/%3E%3Cpath d='M9.5 2.25V5.5h3.25' stroke='%2337352f' stroke-width='1.25' stroke-linejoin='round'/%3E%3Cpath d='M6.25 8h4M6.25 10.25h3' stroke='%2337352f' stroke-width='1.1' stroke-linecap='round'/%3E%3C/svg%3E");
opacity: 0.86;
}
.editor-surface .ProseMirror a.mnote-page-block-link:hover {
color: #37352f;
background: rgba(55, 53, 47, 0.08);
text-decoration: none;
text-decoration-color: rgba(55, 53, 47, 0.68);
}
.editor-surface .ProseMirror a.mnote-uploaded-attachment-row {
+472 -13
View File
@@ -22,12 +22,12 @@ use editor_runtime::block_dnd::{
drop_indicator_from_target as drop_indicator_from_target_runtime, element_within_handle_shell,
hovered_block_from_target as hovered_block_from_target_runtime,
};
use editor_runtime::block_hover_state::{
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
};
use editor_runtime::block_handle_menu_view::{
block_handle_menu_view, BlockHandleMenuSignals, BlockHandleMenuViewProps,
};
use editor_runtime::block_hover_state::{
BlockMenuLayout, DropIndicatorState, HoveredBlockState, PendingDragState,
};
use editor_runtime::block_menu_overlay;
use editor_runtime::block_transform::{
apply_block_delta_to_json, apply_highlight_color, apply_image_align, apply_text_align,
@@ -51,13 +51,13 @@ use editor_runtime::dom_selection::{
};
use editor_runtime::editor_focus::{active_editor_stage, body_has_focus, schedule_editor_focus};
use editor_runtime::mindmap_node_view::{mount_mindmap_shell_impl, unmount_mindmap_shell_impl};
#[cfg(test)]
use editor_runtime::mount::resolve_runtime_delivery_mode;
use editor_runtime::mount::{
initial_content, mount_app_into, runtime_delivery_mode, runtime_editor_instance_id,
runtime_mount_context, set_runtime_mount_context, set_runtime_mount_options, MountOptions,
RuntimeDeliveryMode, RuntimeStandaloneObject,
};
#[cfg(test)]
use editor_runtime::mount::resolve_runtime_delivery_mode;
use editor_runtime::overlays::{
clamp_overlay_anchor, close_editor_floating_overlays_if_escape, image_element_from_target,
image_toolbar_anchor_from_image, open_block_menu_overlay, open_image_toolbar_overlay,
@@ -73,8 +73,8 @@ use editor_runtime::persistence::{
persisted_document_identity, PersistedDocumentIdentity,
};
use editor_runtime::runtime_bridge::{
dispatch_change_event_to_target, dispatch_ready_event_to_target,
dispatch_runtime_event, dispatch_selection_event_to_target, dispatch_state_event_to_target,
dispatch_change_event_to_target, dispatch_ready_event_to_target, dispatch_runtime_event,
dispatch_selection_event_to_target, dispatch_state_event_to_target,
dispatch_status_event_to_target, register_runtime_listener, take_unmount_handle,
};
use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS};
@@ -170,6 +170,71 @@ export function download_mnote_selected_image() {
}
}
export function download_mnote_image_url(src, filenameHint) {
try {
if (!src) return false;
const url = new URL(String(src), window.location.href);
if (!(url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'data:')) return false;
const link = document.createElement('a');
link.href = url.href;
link.download = mnoteImageFilename({ getAttribute: (name) => name === 'title' || name === 'alt' ? String(filenameHint || '') : '' }, url.href);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
return true;
} catch (error) {
console.warn('mnote image preview download failed', error);
return false;
}
}
export function request_mnote_image_preview_fullscreen() {
try {
const target = document.querySelector('[data-testid="image-preview-stage"]')
|| document.querySelector('[data-testid="image-preview-dialog"]');
const request = target?.requestFullscreen || target?.webkitRequestFullscreen || target?.mozRequestFullScreen || target?.msRequestFullscreen;
if (!target || typeof request !== 'function') return false;
const result = request.call(target);
if (result?.catch) result.catch(() => {});
return true;
} catch (error) {
console.warn('mnote image preview fullscreen failed', error);
return false;
}
}
export function open_mnote_page_block_href(href) {
try {
const target = new URL(String(href || ''), window.location.href);
if (target.origin === window.location.origin && target.pathname.startsWith('/documents/')) {
const encodedId = target.pathname.slice('/documents/'.length).split('/')[0] || '';
const documentId = decodeURIComponent(encodedId);
const runtime = window.__mnoteDocumentPaneRuntime;
if (documentId && runtime && typeof runtime.openPrimaryDocument === 'function') {
const result = runtime.openPrimaryDocument({
documentId,
workspaceId: target.searchParams.get('workspaceId') || '',
sourceKind: target.searchParams.get('sourceKind') || '',
rootUri: target.searchParams.get('rootUri') || '',
url: target,
pageBlockNavigation: true,
});
if (result && typeof result.catch === 'function') {
result.catch((error) => {
console.warn('mnote page block pane navigation failed', error);
});
}
return true;
}
}
return false;
} catch (error) {
console.warn('mnote page block navigation failed', error);
return false;
}
}
export function bump_mnote_e27_ai_bridge_request_count() {
try {
const key = '__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__';
@@ -199,6 +264,9 @@ extern "C" {
fn find_mnote_block_anchor(block_id: &str) -> Option<Element>;
fn select_mnote_image_node(image: &Element) -> bool;
fn download_mnote_selected_image() -> bool;
fn download_mnote_image_url(src: &str, filename_hint: &str) -> bool;
fn request_mnote_image_preview_fullscreen() -> bool;
fn open_mnote_page_block_href(href: &str) -> bool;
fn bump_mnote_e27_ai_bridge_request_count() -> u32;
#[wasm_bindgen(catch)]
async fn post_mnote_document_save(body: String) -> Result<JsValue, JsValue>;
@@ -218,6 +286,99 @@ struct ToolbarAlignOption {
alignment: TiptapTextAlign,
}
#[derive(Clone, Debug, PartialEq)]
struct ImagePreviewState {
src: String,
alt: String,
title: String,
index: usize,
total: usize,
zoom: f64,
rotation: i32,
offset_x: f64,
offset_y: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ImagePreviewPanState {
start_x: i32,
start_y: i32,
origin_offset_x: f64,
origin_offset_y: f64,
}
impl ImagePreviewState {
fn filename_hint(&self) -> String {
let title = self.title.trim();
if !title.is_empty() {
return title.to_string();
}
let alt = self.alt.trim();
if !alt.is_empty() {
return alt.to_string();
}
"image".to_string()
}
}
fn image_preview_state_from_element(
image: &Element,
index: usize,
total: usize,
) -> Option<ImagePreviewState> {
let src = image.get_attribute("src")?;
Some(ImagePreviewState {
src,
alt: image.get_attribute("alt").unwrap_or_default(),
title: image.get_attribute("title").unwrap_or_default(),
index,
total,
zoom: 1.0,
rotation: 0,
offset_x: 0.0,
offset_y: 0.0,
})
}
fn image_preview_state_from_image(image: &Element) -> Option<ImagePreviewState> {
let document = window()?.document()?;
let images = document
.query_selector_all(&format!("{EDITOR_ROOT_SELECTOR} img[src]"))
.ok()?;
let total = images.length() as usize;
if total == 0 {
return None;
}
let image_node: &web_sys::Node = image.as_ref();
let mut index = 0_usize;
for current in 0..images.length() {
if images
.item(current)
.map(|node| node.is_same_node(Some(image_node)))
.unwrap_or(false)
{
index = current as usize;
break;
}
}
image_preview_state_from_element(image, index, total)
}
fn image_preview_state_by_index(index: usize) -> Option<ImagePreviewState> {
let document = window()?.document()?;
let images = document
.query_selector_all(&format!("{EDITOR_ROOT_SELECTOR} img[src]"))
.ok()?;
let total = images.length() as usize;
if total == 0 || index >= total {
return None;
}
let element = images.item(index as u32)?.dyn_into::<Element>().ok()?;
image_preview_state_from_element(&element, index, total)
}
const TOOLBAR_TEXT_COLORS: [ToolbarColorOption; 10] = [
ToolbarColorOption {
id: "default",
@@ -1166,6 +1327,8 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let (floating_toolbar_anchor, set_floating_toolbar_anchor) =
signal(None::<FloatingToolbarAnchor>);
let (image_toolbar_anchor, set_image_toolbar_anchor) = signal(None::<ImageToolbarAnchor>);
let (image_preview_state, set_image_preview_state) = signal(None::<ImagePreviewState>);
let (image_preview_pan, set_image_preview_pan) = signal(None::<ImagePreviewPanState>);
let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::<FloatingToolbarAnchor>);
let (slash_open, set_slash_open) = signal(false);
let (slash_index, set_slash_index) = signal(0_usize);
@@ -1806,6 +1969,10 @@ fn App(mount_options: MountOptions) -> impl IntoView {
let set_floating_toolbar_anchor = set_floating_toolbar_anchor;
let image_toolbar_anchor = image_toolbar_anchor;
let set_image_toolbar_anchor = set_image_toolbar_anchor;
let image_preview_state = image_preview_state;
let set_image_preview_state = set_image_preview_state;
let image_preview_pan = image_preview_pan;
let set_image_preview_pan = set_image_preview_pan;
let table_toolbar_open = table_toolbar_open;
let set_table_toolbar_open = set_table_toolbar_open;
let table_options_open = table_options_open;
@@ -1876,6 +2043,15 @@ fn App(mount_options: MountOptions) -> impl IntoView {
drop(keydown_capture_closure);
});
let keydown_handle = window_event_listener(ev::keydown, move |event| {
if image_preview_state.try_get_untracked().flatten().is_some()
&& event.key() == "Escape"
{
event.prevent_default();
let _ = set_image_preview_state.try_set(None);
let _ = set_command_feedback.try_set("已关闭图片预览".to_string());
return;
}
let focused = active_editor_stage();
if !focused {
if body_has_focus() && (event.ctrl_key() || event.meta_key()) && !event.alt_key() {
@@ -2143,7 +2319,36 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}
}
});
on_cleanup(move || drop(keydown_handle));
let preview_mousemove_handle =
window_event_listener(ev::mousemove, move |event: MouseEvent| {
let Some(pan) = image_preview_pan.try_get_untracked().flatten() else {
return;
};
if image_preview_state.try_get_untracked().flatten().is_none() {
let _ = set_image_preview_pan.try_set(None);
return;
}
event.prevent_default();
let offset_x = pan.origin_offset_x + f64::from(event.client_x() - pan.start_x);
let offset_y = pan.origin_offset_y + f64::from(event.client_y() - pan.start_y);
let _ = set_image_preview_state.try_update(|state| {
if let Some(state) = state.as_mut() {
state.offset_x = offset_x;
state.offset_y = offset_y;
}
});
});
let preview_mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| {
if event.button() == 1 || image_preview_pan.try_get_untracked().flatten().is_some() {
event.prevent_default();
let _ = set_image_preview_pan.try_set(None);
}
});
on_cleanup(move || {
drop(keydown_handle);
drop(preview_mousemove_handle);
drop(preview_mouseup_handle);
});
}
{
@@ -2191,10 +2396,9 @@ fn App(mount_options: MountOptions) -> impl IntoView {
else {
return;
};
event.prevent_default();
event.stop_propagation();
if let Some(win) = window() {
let _ = win.location().assign(&href);
if open_mnote_page_block_href(&href) {
event.prevent_default();
event.stop_propagation();
}
});
on_cleanup(move || drop(click_handle));
@@ -2410,7 +2614,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
}
}
if let Some(image) = image_element_from_target(target.clone()) {
if let Some(image) = image_element_from_target(target.clone()) {
let _ = select_mnote_image_node(&image);
set_editor_focused.set(true);
open_image_toolbar_overlay(
@@ -2447,6 +2651,26 @@ fn App(mount_options: MountOptions) -> impl IntoView {
set_turn_into_open.set(false);
set_hovered_block.set(hovered_block_from_target(target));
}
on:dblclick=move |event: MouseEvent| {
let Some(target) = event.target() else {
return;
};
let Some(image) = image_element_from_target(target) else {
return;
};
event.prevent_default();
event.stop_propagation();
let _ = select_mnote_image_node(&image);
set_editor_focused.set(true);
set_image_toolbar_anchor.set(None);
set_table_toolbar_open.set(false);
set_table_options_open.set(false);
set_block_menu_open.set(false);
set_block_menu_anchor.set(None);
set_hovered_block.set(None);
set_image_preview_state.set(image_preview_state_from_image(&image));
set_command_feedback.set("图片预览已打开".to_string());
}
on:mousemove=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
@@ -2614,6 +2838,241 @@ fn App(mount_options: MountOptions) -> impl IntoView {
workspace_id,
set_command_feedback,
)}
{move || {
let Some(preview) = image_preview_state.get() else {
return ().into_any();
};
let preview_index = preview.index;
let preview_total = preview.total;
let preview_src = preview.src.clone();
let preview_alt = preview.alt.clone();
let preview_title = preview.title.clone();
let filename_hint = preview.filename_hint();
let has_prev = preview_index > 0;
let has_next = preview_index + 1 < preview_total;
let image_style = format!(
"transform:translate({}px, {}px) scale({}) rotate({}deg);",
preview.offset_x, preview.offset_y, preview.zoom, preview.rotation
);
view! {
<leptos::portal::Portal>
<div
class="image-preview-dialog"
data-testid="image-preview-dialog"
role="dialog"
aria-modal="true"
aria-label="图片预览"
data-preview-index=preview_index.to_string()
data-preview-total=preview_total.to_string()
data-preview-zoom=preview.zoom.to_string()
data-preview-rotation=preview.rotation.to_string()
data-preview-offset-x=preview.offset_x.to_string()
data-preview-offset-y=preview.offset_y.to_string()
on:click=move |event: MouseEvent| {
let Some(target) = event.target().and_then(target_element) else {
return;
};
if target
.get_attribute("data-testid")
.as_deref()
== Some("image-preview-dialog")
{
set_image_preview_state.set(None);
set_image_preview_pan.set(None);
set_command_feedback.set("已关闭图片预览".to_string());
}
}
>
<div class="image-preview-counter">
{format!("{}/{}", preview_index + 1, preview_total.max(1))}
</div>
<button
type="button"
class="image-preview-close"
data-testid="image-preview-close"
title="关闭"
aria-label="关闭图片预览"
on:click=move |event: MouseEvent| {
event.stop_propagation();
set_image_preview_state.set(None);
set_image_preview_pan.set(None);
set_command_feedback.set("已关闭图片预览".to_string());
}
>"X"</button>
<button
type="button"
class="image-preview-nav image-preview-nav-prev"
data-testid="image-preview-prev"
title="上一张"
aria-label="上一张图片"
disabled=!has_prev
on:click=move |event: MouseEvent| {
event.stop_propagation();
if has_prev {
if let Some(next) = image_preview_state_by_index(preview_index - 1) {
set_image_preview_state.set(Some(next));
}
}
}
>""</button>
<button
type="button"
class="image-preview-nav image-preview-nav-next"
data-testid="image-preview-next"
title="下一张"
aria-label="下一张图片"
disabled=!has_next
on:click=move |event: MouseEvent| {
event.stop_propagation();
if has_next {
if let Some(next) = image_preview_state_by_index(preview_index + 1) {
set_image_preview_state.set(Some(next));
}
}
}
>""</button>
<div
class="image-preview-stage"
data-testid="image-preview-stage"
on:wheel=move |event: WheelEvent| {
event.prevent_default();
event.stop_propagation();
let delta = if event.delta_y() < 0.0 { 0.15 } else { -0.15 };
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = (state.zoom + delta).clamp(0.25, 4.0);
}
});
}
on:mousedown=move |event: MouseEvent| {
if event.button() != 1 {
return;
}
event.prevent_default();
event.stop_propagation();
let Some(state) = image_preview_state.get_untracked() else {
return;
};
set_image_preview_pan.set(Some(ImagePreviewPanState {
start_x: event.client_x(),
start_y: event.client_y(),
origin_offset_x: state.offset_x,
origin_offset_y: state.offset_y,
}));
}
>
<img
class="image-preview-image"
data-testid="image-preview-image"
src=preview_src.clone()
alt=preview_alt.clone()
title=preview_title.clone()
style=image_style.clone()
/>
</div>
<div
class="image-preview-toolbar"
data-testid="image-preview-toolbar"
on:click=move |event: MouseEvent| {
event.stop_propagation();
}
>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-zoom-in"
title="放大"
aria-label="放大图片"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = (state.zoom + 0.25).min(4.0);
}
});
}
>"+"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-zoom-out"
title="缩小"
aria-label="缩小图片"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = (state.zoom - 0.25).max(0.25);
}
});
}
>"-"</button>
<button
type="button"
class="image-preview-tool image-preview-tool-wide"
data-testid="image-preview-one-to-one"
title="1:1"
aria-label="按原始比例查看"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.zoom = 1.0;
state.rotation = 0;
state.offset_x = 0.0;
state.offset_y = 0.0;
}
});
}
>"1:1"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-rotate"
title="旋转"
aria-label="旋转图片"
on:click=move |_| {
set_image_preview_state.update(|state| {
if let Some(state) = state.as_mut() {
state.rotation = (state.rotation + 90) % 360;
}
});
}
>"R"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-download"
title="下载"
aria-label="下载图片"
on:click={
let preview_src = preview_src.clone();
let filename_hint = filename_hint.clone();
move |_| {
if download_mnote_image_url(&preview_src, &filename_hint) {
set_command_feedback.set("已开始下载图片".to_string());
} else {
set_command_feedback.set("图片下载失败".to_string());
}
}
}
>"D"</button>
<button
type="button"
class="image-preview-tool"
data-testid="image-preview-fullscreen"
title="全屏"
aria-label="全屏查看图片"
on:click=move |_| {
if request_mnote_image_preview_fullscreen() {
set_command_feedback.set("已请求全屏查看图片".to_string());
} else {
set_command_feedback.set("当前浏览器不支持图片全屏".to_string());
}
}
>"F"</button>
</div>
</div>
</leptos::portal::Portal>
}.into_any()
}}
{move || {
let Some(anchor) = image_toolbar_anchor.get() else {
return ().into_any();
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
"use strict";
const path = require("node:path");
const { extractZipToTemp, importWolaiExport } = require("./lib/wolai-export-importer");
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
if (["clear-target", "allow-unsafe-clear"].includes(key)) {
args[key] = true;
} else {
args[key] = argv[i + 1];
i += 1;
}
}
return args;
}
function usage() {
console.error([
"用法:",
" node scripts/import-wolai-export.js --source-dir <解包后的Gidnxc目录> --target-root <用户空间目录> --owner-id <用户ID> [--workspace-id <workspaceId>] [--clear-target] [--copy-mode link|copy]",
" node scripts/import-wolai-export.js --zip <wolai.zip> --target-root <用户空间目录> --owner-id <用户ID> [--root-md <Gidnxc/个人空间.md>] [--clear-target]",
].join("\n"));
}
function main() {
const args = parseArgs(process.argv.slice(2));
const targetRoot = args["target-root"];
const ownerId = args["owner-id"];
if (!targetRoot || !ownerId || (!args["source-dir"] && !args.zip)) {
usage();
process.exit(2);
}
let sourceRoot = args["source-dir"];
if (args.zip) {
const tempRoot = extractZipToTemp(path.resolve(args.zip));
sourceRoot = tempRoot;
}
const result = importWolaiExport({
sourceRoot,
targetRoot,
ownerId,
workspaceId: args["workspace-id"],
rootMd: args["root-md"],
clearTarget: Boolean(args["clear-target"]),
allowUnsafeClear: Boolean(args["allow-unsafe-clear"]),
copyMode: args["copy-mode"] === "copy" ? "copy" : "link",
});
console.log(JSON.stringify(result, null, 2));
}
main();
+781
View File
@@ -0,0 +1,781 @@
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const ASSET_DIRS = ["image", "file", "video", "audio", "resources"];
const SYNTHETIC_ORPHAN_REL = "__mnote_synthetic__/unfiled.md";
function toPosix(value) {
return String(value || "").replace(/\\/g, "/");
}
function stripOuterSlashes(value) {
return toPosix(value).replace(/^\/+/, "").replace(/\/+$/, "");
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readUtf8(filePath) {
return fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
}
function writeUtf8(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf8");
}
function sha1Short(value) {
return crypto.createHash("sha1").update(String(value)).digest("hex").slice(0, 8);
}
function safeDecodeUri(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function unescapeMarkdownDestination(value) {
return String(value || "").replace(/\\([!"#$%&'()*+,./:;<=>?@[\\\]^_`{|}~-])/g, "$1");
}
function trimAsciiStart(value) {
return String(value || "").replace(/^[ \t\r\n]+/, "");
}
function isAsciiWhitespace(value) {
return value === " " || value === "\t" || value === "\r" || value === "\n";
}
function isExternalHref(href) {
return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("//");
}
function normalizeSourceHref(baseRel, href) {
const trimmed = String(href || "").trim();
if (!trimmed || isExternalHref(trimmed)) return null;
const decoded = unescapeMarkdownDestination(safeDecodeUri(trimmed));
const baseDir = path.posix.dirname(toPosix(baseRel));
const joined = baseDir === "." ? decoded : path.posix.join(baseDir, decoded);
const normalized = path.posix.normalize(joined);
if (!normalized || normalized === ".") return null;
return stripOuterSlashes(normalized);
}
function findClosingSquare(text, start) {
let escaped = false;
let depth = 0;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === "[") depth += 1;
if (ch === "]") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function findClosingParen(text, start) {
let escaped = false;
let inAngle = false;
let quote = "";
let nested = 0;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (quote) {
if (ch === quote) quote = "";
continue;
}
if (inAngle) {
if (ch === ">") inAngle = false;
continue;
}
if (ch === "<") {
inAngle = true;
continue;
}
if (ch === "\"" || ch === "'") {
quote = ch;
continue;
}
if (ch === "(") {
nested += 1;
continue;
}
if (ch === ")") {
if (nested > 0) {
nested -= 1;
continue;
}
return i;
}
}
return -1;
}
function splitDestinationAndTitle(rawInside) {
const raw = String(rawInside || "").trim();
if (!raw) return null;
if (raw.startsWith("<")) {
const end = raw.indexOf(">");
if (end > 0) {
return {
destination: raw.slice(1, end),
suffix: trimAsciiStart(raw.slice(end + 1)),
};
}
}
let escaped = false;
let nested = 0;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === "(") nested += 1;
if (ch === ")" && nested > 0) nested -= 1;
if (isAsciiWhitespace(ch) && nested === 0) {
const suffix = trimAsciiStart(raw.slice(i));
if (!suffix || (!suffix.startsWith("\"") && !suffix.startsWith("'") && !suffix.startsWith("("))) {
continue;
}
return {
destination: raw.slice(0, i),
suffix,
};
}
}
return { destination: raw, suffix: "" };
}
function walkMarkdownLinks(markdown, visitor) {
let output = "";
let cursor = 0;
for (let i = 0; i < markdown.length; i += 1) {
const isImage = markdown[i] === "!" && markdown[i + 1] === "[";
const linkStart = isImage ? i + 1 : i;
if (markdown[linkStart] !== "[") continue;
const closeSquare = findClosingSquare(markdown, linkStart);
if (closeSquare < 0 || markdown[closeSquare + 1] !== "(") continue;
const openParen = closeSquare + 1;
const closeParen = findClosingParen(markdown, openParen + 1);
if (closeParen < 0) continue;
const rawInside = markdown.slice(openParen + 1, closeParen);
const parsed = splitDestinationAndTitle(rawInside);
if (!parsed) continue;
const replacement = visitor({
destination: parsed.destination,
suffix: parsed.suffix,
isImage,
rawInside,
label: markdown.slice(linkStart + 1, closeSquare),
});
if (typeof replacement === "string") {
output += markdown.slice(cursor, openParen + 1);
output += replacement;
cursor = closeParen;
} else if (replacement && typeof replacement.destination === "string") {
output += markdown.slice(cursor, linkStart + 1);
output += typeof replacement.label === "string"
? replacement.label
: markdown.slice(linkStart + 1, closeSquare);
output += markdown.slice(closeSquare, openParen + 1);
output += replacement.destination;
cursor = closeParen;
}
i = closeParen;
}
if (!output) return markdown;
return output + markdown.slice(cursor);
}
function collectLinks(markdown) {
const links = [];
walkMarkdownLinks(markdown, (link) => {
links.push(link);
return null;
});
return links;
}
function parseTitle(markdown, fallbackFileName) {
for (const line of markdown.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) {
const title = trimmed.slice(2).trim();
if (title) return title;
}
}
return path.posix
.basename(toPosix(fallbackFileName), path.posix.extname(toPosix(fallbackFileName)))
.replace(/_[A-Za-z0-9-]{8,}$/, "")
.trim()
|| "未命名页面";
}
function safeSegment(title, oldRel) {
const chars = Array.from(String(title || "未命名页面")
.replace(/[\/\\\0-\x1F\x7F]/g, "-")
.replace(/[<>:"|?*]/g, "-")
.replace(/\s+/g, " ")
.trim()
.replace(/[. ]+$/g, ""));
const base = chars.slice(0, 80).join("").trim() || `未命名页面-${sha1Short(oldRel)}`;
if (base === "." || base === "..") return `页面-${sha1Short(oldRel)}`;
return base;
}
function uniqueSegment(parentKey, title, oldRel, usedByParent) {
const used = usedByParent.get(parentKey) || new Set();
usedByParent.set(parentKey, used);
const base = safeSegment(title, oldRel);
if (!used.has(base)) {
used.add(base);
return base;
}
const withHash = `${base}-${sha1Short(oldRel)}`;
if (!used.has(withHash)) {
used.add(withHash);
return withHash;
}
let counter = 2;
while (used.has(`${withHash}-${counter}`)) counter += 1;
const value = `${withHash}-${counter}`;
used.add(value);
return value;
}
function listFilesRecursive(root) {
const files = [];
function walk(current) {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
files.push(toPosix(path.relative(root, fullPath)));
}
}
}
if (fs.existsSync(root)) walk(root);
files.sort();
return files;
}
function findExportRoot(sourcePath) {
const resolved = path.resolve(sourcePath);
if (fs.existsSync(path.join(resolved, "pages")) && fs.statSync(path.join(resolved, "pages")).isDirectory()) {
return resolved;
}
const children = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(resolved, entry.name))
.filter((candidate) => fs.existsSync(path.join(candidate, "pages")));
if (children.length === 1) return children[0];
throw new Error(`无法识别 Wolai 导出根目录: ${resolved}`);
}
function findRootMarkdown(sourceRoot, rootMd) {
if (rootMd) {
const target = path.join(sourceRoot, rootMd);
if (!fs.existsSync(target)) throw new Error(`rootMd 不存在: ${target}`);
return stripOuterSlashes(rootMd);
}
const candidates = fs
.readdirSync(sourceRoot, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
.map((entry) => entry.name)
.sort((a, b) => {
const aPersonal = a.includes("个人空间") ? 0 : 1;
const bPersonal = b.includes("个人空间") ? 0 : 1;
return aPersonal - bPersonal || a.length - b.length || a.localeCompare(b);
});
if (!candidates.length) throw new Error(`导出根目录缺少顶层 Markdown: ${sourceRoot}`);
return candidates[0];
}
function buildPageModel(sourceRoot, rootRel) {
const sourceFiles = new Set(listFilesRecursive(sourceRoot));
const pageRels = listFilesRecursive(path.join(sourceRoot, "pages"))
.filter((rel) => rel.toLowerCase().endsWith(".md"))
.map((rel) => `pages/${rel}`);
const allPageRels = [rootRel, ...pageRels];
const pages = new Map();
const children = new Map();
for (const oldRel of allPageRels) {
const absolute = path.join(sourceRoot, oldRel);
const markdown = readUtf8(absolute);
pages.set(oldRel, {
oldRel,
title: parseTitle(markdown, oldRel),
markdown,
sourcePath: absolute,
synthetic: false,
});
}
for (const page of pages.values()) {
const childRels = [];
for (const link of collectLinks(page.markdown)) {
if (link.isImage) continue;
const normalized = normalizeSourceHref(page.oldRel, link.destination);
if (normalized && pages.has(normalized)) childRels.push(normalized);
}
children.set(page.oldRel, childRels);
}
const assignedParent = new Map([[rootRel, null]]);
const orderedChildren = new Map();
const queue = [rootRel];
for (let index = 0; index < queue.length; index += 1) {
const current = queue[index];
const nextChildren = [];
for (const child of children.get(current) || []) {
if (child === current || child === rootRel) continue;
if (!assignedParent.has(child)) {
assignedParent.set(child, current);
nextChildren.push(child);
queue.push(child);
}
}
orderedChildren.set(current, nextChildren);
}
const orphans = allPageRels.filter((oldRel) => !assignedParent.has(oldRel));
if (orphans.length) {
const syntheticDir = path.posix.dirname(SYNTHETIC_ORPHAN_REL);
const orphanMarkdown = [
"# 未归档",
"",
"以下页面在 Wolai 导出中存在,但不在根页面链接树下。",
"",
...orphans.map((oldRel) => {
const href = path.posix.relative(syntheticDir, oldRel);
return `- [${pages.get(oldRel).title}](${href})`;
}),
"",
].join("\n");
pages.set(SYNTHETIC_ORPHAN_REL, {
oldRel: SYNTHETIC_ORPHAN_REL,
title: "未归档",
markdown: orphanMarkdown,
sourcePath: null,
synthetic: true,
});
assignedParent.set(SYNTHETIC_ORPHAN_REL, rootRel);
orderedChildren.set(rootRel, [...(orderedChildren.get(rootRel) || []), SYNTHETIC_ORPHAN_REL]);
orderedChildren.set(SYNTHETIC_ORPHAN_REL, orphans);
for (const oldRel of orphans) assignedParent.set(oldRel, SYNTHETIC_ORPHAN_REL);
}
return { sourceFiles, pages, orderedChildren, assignedParent, rootRel, originalPageCount: allPageRels.length };
}
function assignTargetPaths(model, targetRoot) {
const usedByParent = new Map();
const targetByOldRel = new Map();
const ordered = [];
function assign(oldRel, parentDirRel) {
const page = model.pages.get(oldRel);
const parentKey = parentDirRel || ".";
const segment = uniqueSegment(parentKey, page.title, oldRel, usedByParent);
const dirRel = parentDirRel ? path.posix.join(parentDirRel, segment) : segment;
const markdownRel = path.posix.join(dirRel, `${segment}.md`);
const target = {
oldRel,
title: page.title,
dirRel,
markdownRel,
markdownPath: path.join(targetRoot, markdownRel),
parentOldRel: model.assignedParent.get(oldRel),
synthetic: page.synthetic,
};
targetByOldRel.set(oldRel, target);
ordered.push(oldRel);
for (const child of model.orderedChildren.get(oldRel) || []) {
assign(child, dirRel);
}
}
assign(model.rootRel, "");
return { targetByOldRel, ordered };
}
function copyOrLinkFile(sourcePath, targetPath, copyMode) {
ensureDir(path.dirname(targetPath));
if (fs.existsSync(targetPath)) return "existing";
if (copyMode === "link") {
try {
fs.linkSync(sourcePath, targetPath);
return "linked";
} catch {
fs.copyFileSync(sourcePath, targetPath);
return "copied";
}
}
fs.copyFileSync(sourcePath, targetPath);
return "copied";
}
function assetBucketForSourceRel(sourceRel) {
const top = stripOuterSlashes(sourceRel).split("/")[0];
if (top === "image" || top === "audio" || top === "video") return top;
if (top === "file" || top === "resources") return "file";
return "";
}
function sourceAssetRelForNormalized(normalized, sourceFiles) {
const candidates = [
stripOuterSlashes(normalized),
stripOuterSlashes(String(normalized || "").replace(/^(\.\.\/)+/, "")),
];
for (const candidate of candidates) {
if (candidate && sourceFiles.has(candidate) && assetBucketForSourceRel(candidate)) {
return candidate;
}
}
return "";
}
function encodeLocalIdSegment(value) {
const bytes = Buffer.from(String(value || ""), "utf8");
let encoded = "";
for (const byte of bytes) {
const ch = String.fromCharCode(byte);
if ((byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || ch === "." || ch === "_" || ch === "-") {
encoded += ch;
} else {
encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
}
return encoded;
}
function localMarkdownDocumentId(markdownRel) {
return `local-md:${encodeLocalIdSegment(markdownRel)}`;
}
function uniquePageAssetRel(targetRoot, targetDirRel, bucket, fileName, usedAssetNames) {
const assetDirRel = path.posix.join(targetDirRel, ".assets", bucket);
const used = usedAssetNames.get(assetDirRel) || new Set();
usedAssetNames.set(assetDirRel, used);
const parsed = path.posix.parse(fileName);
const baseName = parsed.base || `asset-${sha1Short(fileName)}`;
const stem = parsed.name || baseName;
const ext = parsed.ext || "";
for (let index = 0; index < 10_000; index += 1) {
const candidateName = index === 0 ? baseName : `${stem}-${index}${ext}`;
const candidateRel = path.posix.join(assetDirRel, candidateName);
if (!used.has(candidateName) && !fs.existsSync(path.join(targetRoot, candidateRel))) {
used.add(candidateName);
return candidateRel;
}
}
const fallbackName = `${stem}-${sha1Short(`${targetDirRel}/${fileName}`)}${ext}`;
used.add(fallbackName);
return path.posix.join(assetDirRel, fallbackName);
}
function createAssetPlacementState(sourceFiles) {
return {
counts: { copied: 0, linked: 0, existing: 0, total: 0, sourceTotal: 0, unused: 0 },
sourceFiles,
placements: new Map(),
usedAssetNames: new Map(),
usedSourceRels: new Set(),
uploadedAssets: {},
sourceAssetRels: [...sourceFiles].filter((rel) => assetBucketForSourceRel(rel)),
};
}
function placeAssetForPage(sourceRoot, targetRoot, copyMode, sourceRel, target, state) {
const bucket = assetBucketForSourceRel(sourceRel);
if (!bucket) return null;
const placementKey = `${target.markdownRel}\0${sourceRel}`;
if (state.placements.has(placementKey)) return state.placements.get(placementKey);
const fileName = path.posix.basename(sourceRel) || `asset-${sha1Short(sourceRel)}`;
const targetRel = uniquePageAssetRel(targetRoot, target.dirRel, bucket, fileName, state.usedAssetNames);
const status = copyOrLinkFile(
path.join(sourceRoot, sourceRel),
path.join(targetRoot, targetRel),
copyMode,
);
state.counts[status] += 1;
state.counts.total += 1;
state.usedSourceRels.add(sourceRel);
state.placements.set(placementKey, targetRel);
state.uploadedAssets[targetRel] = {
documentId: localMarkdownDocumentId(target.markdownRel),
relativePath: targetRel,
fileName: path.posix.basename(targetRel),
createdAtMs: Date.now(),
};
return targetRel;
}
function markdownRelativePath(fromMarkdownRel, toRel) {
const fromDir = path.posix.dirname(fromMarkdownRel);
const relative = path.posix.relative(fromDir, toRel) || path.posix.basename(toRel);
return relative.startsWith(".") ? relative : `./${relative}`.replace(/^\.\.\//, "../");
}
function addMissingSample(stats, page, href, normalized) {
if (!stats.missingSamples) stats.missingSamples = [];
if (stats.missingSamples.length >= 200) return;
stats.missingSamples.push({
page: page.oldRel,
href,
normalized,
});
}
function shouldRewriteMissingHref(href, normalized) {
const decoded = unescapeMarkdownDestination(safeDecodeUri(String(href || "").trim()));
if (!decoded || decoded.toUpperCase() === "NULL") return true;
if (decoded.startsWith("/")) return true;
const localRel = String(normalized || "").replace(/^(\.\.\/)+/, "");
return ASSET_DIRS.some((assetDir) => localRel === assetDir || localRel.startsWith(`${assetDir}/`));
}
function missingLinkLabel(href, normalized) {
const decoded = unescapeMarkdownDestination(safeDecodeUri(String(href || "").trim()));
if (!decoded || decoded.toUpperCase() === "NULL") return "缺失资源";
const basename = path.posix.basename(toPosix(decoded)) || path.posix.basename(toPosix(normalized));
return basename ? `缺失资源: ${basename}` : "缺失资源";
}
function rewriteMarkdown(page, target, targetByOldRel, sourceRoot, targetRoot, copyMode, assetState, stats) {
return walkMarkdownLinks(page.markdown, (link) => {
const normalized = normalizeSourceHref(page.oldRel, link.destination);
if (!normalized) return null;
let targetRel = null;
if (targetByOldRel.has(normalized)) {
targetRel = targetByOldRel.get(normalized).markdownRel;
} else {
const sourceAssetRel = sourceAssetRelForNormalized(normalized, assetState.sourceFiles);
if (sourceAssetRel) {
targetRel = placeAssetForPage(sourceRoot, targetRoot, copyMode, sourceAssetRel, target, assetState);
}
}
if (!targetRel) {
stats.missing += 1;
addMissingSample(stats, page, link.destination, normalized);
if (!shouldRewriteMissingHref(link.destination, normalized)) return null;
stats.rewritten += 1;
return {
label: missingLinkLabel(link.destination, normalized),
destination: `<#wolai-missing-resource>${link.suffix ? ` ${link.suffix}` : ""}`,
};
}
stats.rewritten += 1;
const relative = markdownRelativePath(target.markdownRel, targetRel).replace(/^\.\//, "");
return `<${relative}>${link.suffix ? ` ${link.suffix}` : ""}`;
});
}
function validateClearTarget(targetRoot, ownerId, allowUnsafeClear) {
if (allowUnsafeClear) return;
const normalized = path.resolve(targetRoot);
const marker = `${path.sep}Mnote_data${path.sep}users${path.sep}${ownerId}${path.sep}workspaces${path.sep}`;
if (!normalized.includes(marker)) {
throw new Error(`拒绝清空非用户工作区目录: ${normalized}`);
}
}
function clearTarget(targetRoot, ownerId, allowUnsafeClear) {
validateClearTarget(targetRoot, ownerId, allowUnsafeClear);
ensureDir(targetRoot);
for (const entry of fs.readdirSync(targetRoot)) {
fs.rmSync(path.join(targetRoot, entry), { recursive: true, force: true });
}
}
function writeWorkspaceMetadata(targetRoot, ownerId, workspaceId, result, fileOrderParents, uploadedAssets) {
const metadataDir = path.join(targetRoot, ".mnote");
const indexDir = path.join(metadataDir, "index");
ensureDir(metadataDir);
ensureDir(indexDir);
writeUtf8(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
);
writeUtf8(
path.join(metadataDir, "file-order.json"),
`${JSON.stringify({ version: 1, parents: fileOrderParents }, null, 2)}\n`,
);
writeUtf8(
path.join(metadataDir, "wolai-import.json"),
`${JSON.stringify(result, null, 2)}\n`,
);
writeUtf8(
path.join(metadataDir, "uploaded-assets.json"),
`${JSON.stringify({ version: 1, entries: uploadedAssets }, null, 2)}\n`,
);
writeUtf8(
path.join(indexDir, "local-index-settings.json"),
`${JSON.stringify({
schema: "mnote.local_index.settings.v1",
includePaths: ["."],
scheduleMode: "daily",
scheduleTime: "02:00",
scheduleDate: null,
runOnChange: false,
updatedAt: Date.now(),
}, null, 2)}\n`,
);
}
function buildFileOrder(model, targetByOldRel) {
const parents = { ".": [] };
const rootTarget = targetByOldRel.get(model.rootRel);
parents["."].push(rootTarget.dirRel);
for (const [oldRel, children] of model.orderedChildren.entries()) {
const parentTarget = targetByOldRel.get(oldRel);
if (!parentTarget) continue;
parents[parentTarget.dirRel] = children
.map((child) => targetByOldRel.get(child))
.filter(Boolean)
.map((target) => target.dirRel);
}
return parents;
}
function importWolaiExport(options) {
const sourceRoot = findExportRoot(options.sourceRoot || options.sourceDir || options.source);
const targetRoot = path.resolve(options.targetRoot);
const ownerId = String(options.ownerId || "").trim();
if (!ownerId) throw new Error("缺少 ownerId");
const workspaceId = String(options.workspaceId || `local-ws:${ownerId}:my-space`).trim();
const copyMode = options.copyMode === "copy" ? "copy" : "link";
const rootRel = findRootMarkdown(sourceRoot, options.rootMd);
if (options.clearTarget) clearTarget(targetRoot, ownerId, Boolean(options.allowUnsafeClear));
ensureDir(targetRoot);
const model = buildPageModel(sourceRoot, rootRel);
const { targetByOldRel, ordered } = assignTargetPaths(model, targetRoot);
const assetState = createAssetPlacementState(model.sourceFiles);
const linkStats = { rewritten: 0, missing: 0 };
for (const oldRel of ordered) {
const page = model.pages.get(oldRel);
const target = targetByOldRel.get(oldRel);
const rewritten = rewriteMarkdown(
page,
target,
targetByOldRel,
sourceRoot,
targetRoot,
copyMode,
assetState,
linkStats,
);
writeUtf8(target.markdownPath, rewritten.endsWith("\n") ? rewritten : `${rewritten}\n`);
}
const pageMap = ordered.map((oldRel) => {
const target = targetByOldRel.get(oldRel);
return {
oldRelativePath: oldRel,
newRelativePath: target.markdownRel,
title: target.title,
parentOldRelativePath: target.parentOldRel,
synthetic: target.synthetic,
};
});
const syntheticCount = pageMap.filter((entry) => entry.synthetic).length;
assetState.counts.sourceTotal = assetState.sourceAssetRels.length;
assetState.counts.unused = Math.max(0, assetState.sourceAssetRels.length - assetState.usedSourceRels.size);
const result = {
ok: true,
sourceRoot,
targetRoot,
ownerId,
workspaceId,
root: {
oldRelativePath: rootRel,
newRelativePath: targetByOldRel.get(rootRel).markdownRel,
},
pages: {
imported: model.originalPageCount,
synthetic: syntheticCount,
written: pageMap.length,
},
assets: assetState.counts,
links: linkStats,
pageMap,
};
writeWorkspaceMetadata(
targetRoot,
ownerId,
workspaceId,
result,
buildFileOrder(model, targetByOldRel),
assetState.uploadedAssets,
);
return result;
}
function assertSafeZipEntries(zipPath) {
const result = spawnSync("zipinfo", ["-1", zipPath], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
if (result.status !== 0) {
throw new Error(`zipinfo 读取失败: ${result.stderr || result.stdout}`);
}
for (const entry of result.stdout.split(/\r?\n/).filter(Boolean)) {
if (entry.startsWith("/") || entry.includes("\\") || entry.split("/").includes("..")) {
throw new Error(`ZIP 包含不安全路径: ${entry}`);
}
}
}
function extractZipToTemp(zipPath) {
assertSafeZipEntries(zipPath);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-wolai-zip-"));
const result = spawnSync("unzip", ["-q", zipPath, "-d", tempRoot], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) {
throw new Error(`unzip 解包失败: ${result.stderr || result.stdout}`);
}
return tempRoot;
}
module.exports = {
importWolaiExport,
extractZipToTemp,
};
-80
View File
@@ -1,80 +0,0 @@
// 说明:用于本地快速验证 Wolai ZIP 导入流程(Playwright)。
// 注意:仅用于开发/测试;请勿在生产环境使用。
const { chromium } = require("@playwright/test");
const BASE_URL = process.env.MNOTE_TEST_BASE_URL || "http://127.0.0.1:3000";
const ZIP_PATH = process.env.MNOTE_TEST_ZIP_PATH || "C:\\\\Users\\\\liaib\\\\Downloads\\\\软件开发.zip";
const ROOT_MD_PATH = process.env.MNOTE_TEST_ROOT_MD_PATH || "ChB6p4/软件开发.md";
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(60_000);
// 1) 打开 /auth(若已登录会跳转到 /)
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle" });
// 2) 如果还没登录,点“测试账号快速登录”
const url1 = page.url();
if (url1.includes("/auth")) {
const quickLogin = page.getByRole("button", { name: "测试账号快速登录" });
if (await quickLogin.isVisible().catch(() => false)) {
await quickLogin.click();
} else {
// 兜底:尝试手动点击“登录”按钮(如果用户已填写)
const submit = page.getByRole("button", { name: "登录" });
if (await submit.isVisible().catch(() => false)) await submit.click();
}
}
// 3) 等跳转到首页(或至少不在 /auth)
await page.waitForURL((u) => !u.toString().includes("/auth"), { timeout: 120_000 });
// 4) 打开导入页
await page.goto(`${BASE_URL}/wolai-import`, { waitUntil: "networkidle" });
// 5) 填本地路径(大文件不要上传)
const zipInput = page.locator('input[placeholder*="个人.zip"]').first();
await zipInput.fill(ZIP_PATH);
const rootInput = page.locator('input[placeholder*="dQeAax/个人.md"]').first();
await rootInput.fill(ROOT_MD_PATH);
// 6) 开始导入
const respPromise = page.waitForResponse(
(r) => r.url().includes("/api/wolai-import") && r.request().method() === "POST",
{ timeout: 20 * 60_000 },
);
await page.getByRole("button", { name: "开始导入" }).click();
const resp = await respPromise;
const status = resp.status();
const text = await resp.text().catch(() => "");
console.log("导入接口返回:", status);
if (text) {
console.log("响应体:", text.slice(0, 4000));
}
if (status >= 200 && status < 300) {
let json = null;
try {
json = JSON.parse(text);
} catch {
// ignore
}
const rootDocumentId = json && typeof json.rootDocumentId === "string" ? json.rootDocumentId : "";
if (rootDocumentId) {
await page.goto(`${BASE_URL}/documents/${encodeURIComponent(rootDocumentId)}`, { waitUntil: "networkidle" });
console.log("导入成功,已打开:", page.url());
}
}
await browser.close();
}
main().catch((err) => {
console.error("导入测试失败:", err);
process.exit(1);
});
@@ -78,6 +78,10 @@ function localMarkdownDocumentPath(documentId) {
return decodeURIComponent(encoded.replace(/~/g, "%"));
}
function cssEscape(value) {
return String(value).replace(/["\\]/g, "\\$&");
}
async function main() {
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
@@ -456,6 +460,30 @@ async function main() {
);
await page.evaluate(() => {
const root = document.getElementById("sidebar-file-tree-root");
const original = Element.prototype.replaceChildren;
window.__mnoteRevealReplaceChildrenProbe = {
root,
original,
rootCalls: 0,
descendantCalls: [],
};
Element.prototype.replaceChildren = function(...nodes) {
const probe = window.__mnoteRevealReplaceChildrenProbe;
if (this === probe.root) {
probe.rootCalls += 1;
} else if (probe.root?.contains(this)) {
probe.descendantCalls.push({
className: this instanceof HTMLElement ? this.className : "",
relativePath: this.closest(".tree-node")?.querySelector(":scope > .tree-row")?.getAttribute("data-local-relative-path") || "",
incomingChildren: nodes.length,
});
}
return probe.original.apply(this, nodes);
};
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
}));
window.dispatchEvent(new CustomEvent("mnote:primary-document-activated", {
detail: { documentId: "local-md:design~2Freveal-parent~2Fchild~2Fnote.md" },
}));
@@ -468,7 +496,90 @@ async function main() {
&& row.getAttribute("data-focused") === "true"
&& row.getAttribute("data-active") === "true";
}, revealTargetSelector, { timeout: UI_TIMEOUT_MS });
const revealReplaceChildrenProbe = await page.evaluate(() => {
const probe = window.__mnoteRevealReplaceChildrenProbe;
if (!probe) return null;
Element.prototype.replaceChildren = probe.original;
return {
rootCalls: probe.rootCalls,
descendantCalls: probe.descendantCalls,
};
});
assert.equal(
revealReplaceChildrenProbe?.rootCalls,
0,
`打开深层页面只能定位 FileTree,不能替换 FileTree 根节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
assert.ok(
(revealReplaceChildrenProbe?.descendantCalls || []).every((call) => String(call.className).includes("tree-children")),
`打开深层页面不能替换 FileTree 的非局部节点: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
assert.ok(
(revealReplaceChildrenProbe?.descendantCalls || []).length <= 2,
`已存在的祖先节点不应在页面打开时被重复重绘: ${JSON.stringify(revealReplaceChildrenProbe)}`,
);
await page.locator('[data-mnote-sidebar-tree-tab="page"]').click({ timeout: UI_TIMEOUT_MS });
const pageTreeTargetDocumentId = "local-md:design~2Freveal-parent~2Fchild~2Fnote.md";
const pageTreeTargetSelector = `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${cssEscape(pageTreeTargetDocumentId)}"]`;
await page.locator(pageTreeTargetSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const pageTreeFileSelectionBeforeOpen = await page.evaluate(() => {
const root = document.getElementById("sidebar-file-tree-root");
const selected = root?.querySelector('.tree-row[data-selected="true"]');
return selected?.getAttribute("data-row-id") || "";
});
await page.evaluate(() => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const pageRoot = document.getElementById("sidebar-tree-root");
const original = Element.prototype.replaceChildren;
window.__mnotePageTreeOpenProbe = {
fileRoot,
pageRoot,
original,
fileRootCalls: 0,
pageRootCalls: 0,
};
Element.prototype.replaceChildren = function(...nodes) {
const probe = window.__mnotePageTreeOpenProbe;
if (this === probe.fileRoot) probe.fileRootCalls += 1;
if (this === probe.pageRoot) probe.pageRootCalls += 1;
return probe.original.apply(this, nodes);
};
});
await page.locator(`${pageTreeTargetSelector} .tree-link`).click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction((documentId) => {
const activeTab = document.querySelector('[data-mnote-sidebar-tree-tab="page"][aria-selected="true"]');
const row = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"]`);
return activeTab && row && row.getAttribute("data-active") === "true";
}, pageTreeTargetDocumentId, { timeout: UI_TIMEOUT_MS });
const pageTreeOpenProbe = await page.evaluate(() => {
const probe = window.__mnotePageTreeOpenProbe;
if (!probe) return null;
Element.prototype.replaceChildren = probe.original;
const selected = probe.fileRoot?.querySelector('.tree-row[data-selected="true"]');
return {
fileRootCalls: probe.fileRootCalls,
pageRootCalls: probe.pageRootCalls,
fileTreeSelection: selected?.getAttribute("data-row-id") || "",
};
});
assert.equal(
pageTreeOpenProbe?.fileRootCalls,
0,
`页面树打开页面时不得全量重建 FileTree: ${JSON.stringify(pageTreeOpenProbe)}`,
);
assert.equal(
pageTreeOpenProbe?.pageRootCalls,
0,
`页面树打开已加载页面时不得全量重建 PageTree: ${JSON.stringify(pageTreeOpenProbe)}`,
);
assert.equal(
pageTreeOpenProbe?.fileTreeSelection,
pageTreeFileSelectionBeforeOpen,
`页面树激活时打开页面不得改写 FileTree selection: ${JSON.stringify(pageTreeOpenProbe)}`,
);
await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS });
const slowScopeSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/slow-scope"]';
await page.locator(slowScopeSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(`${slowScopeSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS });
@@ -484,8 +595,7 @@ async function main() {
const staleRowsInDocsScope = await page.locator('#sidebar-file-tree-root .tree-row[data-local-relative-path^="design/slow-scope/"]').count();
assert.equal(staleRowsInDocsScope, 0, "慢请求返回后不得把旧 design scope children patch 到 docs scope");
const rootText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
assert.match(rootText, /target/);
await page.locator("#sidebar-file-tree-root").innerText({ timeout: UI_TIMEOUT_MS });
const openedDocumentId = "local-md:docs~2Fopened-rename.md";
const renamedDocumentId = "local-md:docs~2Fopened-renamed.md";
@@ -187,6 +187,33 @@ async function main() {
await page.goto(rootUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await assertNavigationPage(page);
assert.equal(await page.locator('[data-root-active-page-id="local-md:Home.md"]').count(), 0, "打开文件夹 root 不应自动打开 Home.md");
const homePageBlock = page.locator('[data-testid="mnote-navigation-pages"] [data-navigation-item-kind="page"][data-local-relative-path="Home.md"]').first();
await homePageBlock.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const homePageBlockVisual = await homePageBlock.evaluate((anchor) => {
const style = getComputedStyle(anchor);
const before = getComputedStyle(anchor, "::before");
return {
className: anchor.className,
blockType: anchor.getAttribute("data-block-type"),
area: anchor.getAttribute("data-area"),
display: style.display,
cursor: style.cursor,
beforeWidth: before.width,
beforeHeight: before.height,
};
});
assert.ok(
homePageBlockVisual.className.split(/\s+/).includes("mnote-page-block-link"),
`首页子页面必须使用页面块链接语义: ${JSON.stringify(homePageBlockVisual)}`,
);
assert.equal(homePageBlockVisual.blockType, "page", `首页页面块应声明 page 类型: ${JSON.stringify(homePageBlockVisual)}`);
assert.equal(homePageBlockVisual.area, "page-block", `首页页面块应标记 page-block 区域: ${JSON.stringify(homePageBlockVisual)}`);
assert.equal(homePageBlockVisual.display, "inline-flex", `首页页面块应使用图标+标题布局: ${JSON.stringify(homePageBlockVisual)}`);
assert.equal(homePageBlockVisual.cursor, "pointer", `首页页面块应明确可点击: ${JSON.stringify(homePageBlockVisual)}`);
assert.ok(
parseFloat(homePageBlockVisual.beforeWidth) >= 14 && parseFloat(homePageBlockVisual.beforeHeight) >= 14,
`首页页面块应显示页面图标: ${JSON.stringify(homePageBlockVisual)}`,
);
await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
timeout: UI_TIMEOUT_MS,
});
@@ -65,27 +65,24 @@ async function openDocument(page, root, relativePath) {
});
}
async function waitForDeletedConflict(page, retainedText) {
async function waitForDeletedHomeFallback(page, documentId) {
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
(expectedDocumentId) => {
const url = new URL(window.location.href);
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
const snapshot = typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
: null;
const envelopeDirtyState = snapshot?.sessions?.[0]?.lastExternalConflictEnvelope?.dirtyState || "";
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& editorText.includes(needle)
&& panelText.includes("已被删除")
&& envelopeDirtyState === "Deleted";
return url.pathname === "/"
&& url.searchParams.get("missingPage") === expectedDocumentId
&& (url.searchParams.get("routeGuard") === "local_markdown_deleted"
|| url.searchParams.get("routeGuard") === "local_markdown_not_found")
&& !panelText.includes("文件冲突");
},
retainedText,
documentId,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
url: window.location.href,
missingPage: new URL(window.location.href).searchParams.get("missingPage") || "",
routeGuard: new URL(window.location.href).searchParams.get("routeGuard") || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
@@ -93,22 +90,24 @@ async function waitForDeletedConflict(page, retainedText) {
}));
}
async function waitForExternalMoveConflict(page, retainedText) {
async function waitForExternalMoveHomeFallback(page, documentId) {
await page.waitForFunction(
(needle) => {
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorText = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "";
(expectedDocumentId) => {
const url = new URL(window.location.href);
const panelText = document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "";
return root?.getAttribute("data-runtime-editor-status") === "external-change-conflict"
&& editorText.includes(needle)
&& panelText.includes("文件冲突");
return url.pathname === "/"
&& url.searchParams.get("missingPage") === expectedDocumentId
&& (url.searchParams.get("routeGuard") === "local_markdown_deleted"
|| url.searchParams.get("routeGuard") === "local_markdown_not_found")
&& !panelText.includes("文件冲突");
},
retainedText,
documentId,
{ timeout: UI_TIMEOUT_MS },
);
return await page.evaluate(() => ({
status: document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-runtime-editor-status") || "",
editorText: document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror')?.textContent || "",
url: window.location.href,
missingPage: new URL(window.location.href).searchParams.get("missingPage") || "",
routeGuard: new URL(window.location.href).searchParams.get("routeGuard") || "",
panelText: document.querySelector('[data-testid="mnote-editor-conflict-panel"]')?.textContent || "",
sessions: typeof window.__mnoteDebugDocumentSessions?.snapshot === "function"
? window.__mnoteDebugDocumentSessions.snapshot()
@@ -140,11 +139,11 @@ async function main() {
await quickLogin(page);
await openDocument(page, root, deleteRelativePath);
fs.rmSync(path.join(root, deleteRelativePath));
const deletedState = await waitForDeletedConflict(page, "保留删除 buffer");
const deletedState = await waitForDeletedHomeFallback(page, localMdDocumentId(deleteRelativePath));
await openDocument(page, root, moveRelativePath);
fs.renameSync(path.join(root, moveRelativePath), path.join(root, "Moved.md"));
const movedState = await waitForExternalMoveConflict(page, "保留移动 buffer");
const movedState = await waitForExternalMoveHomeFallback(page, localMdDocumentId(moveRelativePath));
console.log(JSON.stringify({
ok: true,
@@ -15,7 +15,15 @@ function loadConversionRuntime() {
const source = fs
.readFileSync(RUNTIME_PATH, "utf8")
.replace(/\bexport const\s+([A-Za-z0-9_]+)\s*=/g, "const $1 =");
const sandbox = { console };
const sandbox = {
console,
URL,
window: {
location: {
origin: "http://127.0.0.1:3000",
},
},
};
vm.runInNewContext(
`${source}\n;globalThis.__mnoteExports = { pageBodyTiptapDocumentSource, pageBodyTiptapDocument };`,
sandbox,
@@ -36,6 +44,10 @@ function taskItemChecked(doc) {
return doc?.content?.[0]?.content?.[0]?.attrs?.checked;
}
function firstLinkMarkAttrs(doc) {
return doc?.content?.[0]?.content?.[0]?.marks?.find((mark) => mark.type === "link")?.attrs || {};
}
const {
pageBodyTiptapDocumentSource,
pageBodyTiptapDocument,
@@ -121,6 +133,93 @@ assert.equal(
true,
);
const localWithPdfAttachment = {
projectionSource: "local_markdown.content",
blockDocument: {
documentId: "local-md:实验资料~2F核磁~2F核磁.md",
rootBlockIds: ["pdf-1"],
blocks: [{
blockId: "pdf-1",
type: "media",
attrs: {
name: "核磁常见杂质化学位移表.pdf",
sourcePath: ".assets/file/核磁常见杂质化学位移表.pdf",
},
contentNodes: [{
payload: {
type: "text",
text: "核磁常见杂质化学位移表.pdf",
marks: [],
},
}],
}],
},
content: [{
id: "pdf-1",
type: "media",
props: {
name: "核磁常见杂质化学位移表.pdf",
sourcePath: ".assets/file/核磁常见杂质化学位移表.pdf",
},
}],
attachmentRefs: [{
rawHref: ".assets/file/核磁常见杂质化学位移表.pdf",
normalizedHref: ".assets/file/核磁常见杂质化学位移表.pdf",
label: "核磁常见杂质化学位移表.pdf",
ext: "pdf",
fileSize: 49510,
exists: true,
}],
};
const pdfLinkAttrs = firstLinkMarkAttrs(pageBodyTiptapDocument(localWithPdfAttachment, "", {
sourceKind: "local_folder",
rootUri: "file:///tmp/mnote-task522",
documentId: "local-md:实验资料~2F核磁~2F核磁.md",
}));
const pdfOpenUrl = new URL(pdfLinkAttrs.href);
assert.equal(pdfOpenUrl.pathname, "/api/local-folder/files/open");
assert.equal(pdfOpenUrl.searchParams.get("rootUri"), "file:///tmp/mnote-task522");
assert.equal(
pdfOpenUrl.searchParams.get("path"),
"实验资料/核磁/.assets/file/核磁常见杂质化学位移表.pdf",
);
assert.ok(pdfLinkAttrs.class.includes("mnote-uploaded-attachment-pdf"));
assert.equal(pdfLinkAttrs["data-file-size"], "48.35 KB");
const localWithPageReference = {
projectionSource: "local_markdown.content",
blockDocument: {
documentId: "local-md:liaibo~2F项目~2F项目.md",
rootBlockIds: ["page-ref-1"],
blocks: [{
blockId: "page-ref-1",
type: "page_reference",
attrs: {
title: "完结项目",
sourcePath: "liaibo/项目/完结项目/完结项目.md",
},
contentNodes: [{
payload: {
type: "text",
text: "完结项目",
marks: [],
},
}],
}],
},
};
const pageRefMarkAttrs = firstLinkMarkAttrs(pageBodyTiptapDocument(localWithPageReference, "", {
sourceKind: "local_folder",
rootUri: "file:///tmp/mnote-task522",
documentId: "local-md:liaibo~2F项目~2F项目.md",
}));
assert.equal(pageRefMarkAttrs.class, "mnote-page-block-link");
assert.equal(pageRefMarkAttrs.target, "_self");
assert.ok(pageRefMarkAttrs.href.startsWith("/documents/local-md:liaibo"));
const pageRefUrl = new URL(pageRefMarkAttrs.href, "http://127.0.0.1:3000");
assert.equal(pageRefUrl.searchParams.get("sourceKind"), "local_folder");
assert.equal(pageRefUrl.searchParams.get("rootUri"), "file:///tmp/mnote-task522");
const localLegacyOnly = {
projectionSource: "local_markdown.content",
content: legacyContent,
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_WOLAI_ASSET_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
const DOCUMENT_RELATIVE_PATH = process.env.MNOTE_WOLAI_ASSET_SMOKE_DOCUMENT
|| "liaibo的个人空间/知识/实验资料-工具书/核磁常见杂质化学位移表(核磁溶剂峰)/核磁常见杂质化学位移表(核磁溶剂峰).md";
const USERNAME = process.env.MNOTE_WOLAI_ASSET_SMOKE_USER || "mnote.e2e@example.com";
const PASSWORD = process.env.MNOTE_WOLAI_ASSET_SMOKE_PASSWORD || "MnoteE2E123!";
const OUT_DIR = path.join(process.cwd(), "tmp", "task800-wolai-assets-page-local-smoke");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function encodeLocalIdSegment(value) {
const bytes = Buffer.from(String(value || ""), "utf8");
let encoded = "";
for (const byte of bytes) {
const character = String.fromCharCode(byte);
if (
(byte >= 48 && byte <= 57)
|| (byte >= 65 && byte <= 90)
|| (byte >= 97 && byte <= 122)
|| character === "."
|| character === "_"
|| character === "-"
) {
encoded += character;
} else {
encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
}
return encoded;
}
function documentUrl() {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(`local-md:${encodeLocalIdSegment(DOCUMENT_RELATIVE_PATH)}`)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function firstMarkdownImageHref(markdown) {
const match = String(markdown || "").match(/!\[[^\]]*\]\(<([^>]+)>\)|!\[[^\]]*\]\(([^)\s]+)\)/);
return match ? String(match[1] || match[2] || "").trim() : "";
}
function firstMarkdownPdfHref(markdown) {
const match = String(markdown || "").match(/\[[^\]]*\.pdf[^\]]*\]\(<([^>]+\.pdf)>[^)]*\)|\[[^\]]*\.pdf[^\]]*\]\(([^)\s]+\.pdf)(?:\s+["'][^"']+["'])?\)/i);
return match ? String(match[1] || match[2] || "").trim() : "";
}
function assetOpenUrl(assetRelativePath) {
const url = new URL(`${BASE_URL}/api/local-folder/files/open`);
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
url.searchParams.set("path", assetRelativePath);
return url.toString();
}
async function signIn(request) {
const response = await request.post(`${BASE_URL}/api/auth`, {
headers: { "content-type": "application/json", accept: "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: USERNAME,
password: PASSWORD,
flow: "signIn",
},
},
},
});
assert.equal(response.status(), 200, `登录失败: ${response.status()} ${await response.text()}`);
const whoami = await (await request.get(`${BASE_URL}/api/auth/whoami`, {
headers: { accept: "application/json" },
})).json();
assert.ok(
[whoami.userId, whoami.email, whoami.username].includes(USERNAME),
`登录用户不匹配: ${JSON.stringify(whoami)}`,
);
return whoami;
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const markdownPath = path.join(ROOT_PATH, DOCUMENT_RELATIVE_PATH);
const markdown = fs.readFileSync(markdownPath, "utf8");
const firstHref = firstMarkdownImageHref(markdown);
assert.ok(firstHref, `目标页面缺少 Markdown 图片: ${markdownPath}`);
const assetRelativePath = path.posix.normalize(path.posix.join(
path.posix.dirname(DOCUMENT_RELATIVE_PATH),
firstHref,
));
const pdfHref = firstMarkdownPdfHref(markdown);
assert.ok(pdfHref, `目标页面缺少 PDF 附件链接: ${markdownPath}`);
const pdfAssetRelativePath = path.posix.normalize(path.posix.join(
path.posix.dirname(DOCUMENT_RELATIVE_PATH),
pdfHref,
));
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
});
const page = await context.newPage();
page.setDefaultTimeout(Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000));
try {
const whoami = await signIn(context.request);
const assetResponse = await context.request.get(assetOpenUrl(assetRelativePath));
assert.equal(assetResponse.status(), 200, `图片资源打开失败: ${assetResponse.status()} ${await assetResponse.text().catch(() => "")}`);
const contentType = assetResponse.headers()["content-type"] || "";
assert.match(contentType, /^image\//);
const imageBytes = (await assetResponse.body()).length;
assert.ok(imageBytes > 0, "图片资源为空");
const pdfResponse = await context.request.get(assetOpenUrl(pdfAssetRelativePath));
assert.equal(pdfResponse.status(), 200, `PDF 附件资源打开失败: ${pdfResponse.status()} ${await pdfResponse.text().catch(() => "")}`);
const pdfContentType = pdfResponse.headers()["content-type"] || "";
assert.match(pdfContentType, /application\/pdf|application\/octet-stream/i);
const pdfBytes = (await pdfResponse.body()).length;
assert.ok(pdfBytes > 0, "PDF 附件资源为空");
const treeUrl = new URL(`${BASE_URL}/api/tree/projections/file/children`);
treeUrl.searchParams.set("sourceKind", "local_folder");
treeUrl.searchParams.set("rootUri", fileUrl(ROOT_PATH));
treeUrl.searchParams.set("parentRelativePath", path.posix.dirname(DOCUMENT_RELATIVE_PATH));
const treeResponse = await context.request.get(treeUrl.toString(), {
headers: { accept: "application/json" },
});
assert.equal(treeResponse.status(), 200, `FileTree 读取失败: ${treeResponse.status()} ${await treeResponse.text()}`);
const treeJson = await treeResponse.json();
const fileTreeTitles = (treeJson.result?.items || []).map((item) => item.title);
assert.ok(!fileTreeTitles.includes(".assets"), `.assets 泄露到 FileTree: ${fileTreeTitles.join(", ")}`);
await page.goto(documentUrl(), { waitUntil: "domcontentloaded" });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ state: "visible" });
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ state: "visible" });
await page.waitForFunction(() => Array.from(document.querySelectorAll(".editor-surface img"))
.some((img) => img.complete && img.naturalWidth > 0 && img.naturalHeight > 0));
const imageState = await page.evaluate(() => Array.from(document.querySelectorAll(".editor-surface img")).slice(0, 5).map((img) => ({
src: img.getAttribute("src"),
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
complete: img.complete,
})));
assert.ok(imageState[0]?.naturalWidth > 0 && imageState[0]?.naturalHeight > 0, JSON.stringify(imageState[0] || null));
const pdfLink = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a.mnote-uploaded-attachment-row.mnote-uploaded-attachment-pdf').first();
await pdfLink.waitFor({ state: "visible" });
const pdfLinkState = await pdfLink.evaluate((link) => {
const beforeStyle = getComputedStyle(link, "::before");
const afterStyle = getComputedStyle(link, "::after");
return {
text: link.textContent || "",
href: link.getAttribute("href") || "",
className: link.className || "",
fileSize: link.getAttribute("data-file-size") || "",
iconBackground: beforeStyle.backgroundColor,
sizeContent: afterStyle.content || "",
};
});
assert.match(pdfLinkState.text, /核磁常见杂质化学位移表\.pdf/);
assert.ok(pdfLinkState.className.includes("mnote-uploaded-attachment-pdf"), JSON.stringify(pdfLinkState));
assert.match(pdfLinkState.fileSize, /^[\d.]+ (B|KB|MB)$/);
assert.match(pdfLinkState.iconBackground, /rgb\(217, 72, 65\)|rgb\(239, 68, 68\)/, JSON.stringify(pdfLinkState));
assert.ok(pdfLinkState.sizeContent.includes(pdfLinkState.fileSize), JSON.stringify(pdfLinkState));
const pdfOpenUrl = new URL(pdfLinkState.href);
assert.equal(pdfOpenUrl.pathname, "/api/local-folder/files/open");
assert.equal(pdfOpenUrl.searchParams.get("path"), pdfAssetRelativePath);
const screenshotPath = path.join(OUT_DIR, "first-chapter-image.png");
await page.screenshot({ path: screenshotPath, fullPage: false });
const result = {
ok: true,
whoami,
documentRelativePath: DOCUMENT_RELATIVE_PATH,
assetRelativePath,
pdfAssetRelativePath,
contentType,
imageBytes,
pdfContentType,
pdfBytes,
fileTreeTitles,
imageState,
pdfLinkState,
screenshotPath,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,339 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
let BASE_URL = (process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SERVER_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_SERVER_TIMEOUT_MS || 90_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task801-image-double-click-preview-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const IMAGE_SRC = "/api/editor/image-placeholder.svg";
const ACTOR_ID = `task801-${process.pid}-${Date.now().toString(36)}`;
const RELATIVE_PATH = "README.md";
function assertImagePreviewSourceBoundary() {
const source = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(
source.includes("ImagePreviewState") && source.includes("image_preview_state_from_image"),
"图片双击预览必须有独立 preview state,不能复用普通 image toolbar 状态",
);
assert(
source.includes("on:dblclick") && source.includes('data-testid="image-preview-dialog"'),
"图片双击必须打开稳定可测的全屏预览 dialog",
);
assert(
source.includes("on:wheel") && source.includes("data-preview-offset-x") && source.includes("ImagePreviewPanState"),
"图片预览必须支持滚轮缩放和中键拖拽平移,并暴露 offset 状态用于回归验证",
);
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function localMdDocumentId(relativePath) {
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}
function documentUrl(root, relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
function writeWorkspaceManifest(root, ownerId) {
const metadataDir = path.join(root, ".mnote");
fs.mkdirSync(metadataDir, { recursive: true });
fs.writeFileSync(
path.join(metadataDir, "workspace.json"),
`${JSON.stringify({
workspaceId: `local-ws:${ownerId}:task801`,
ownerId,
createdAt: new Date().toISOString(),
capabilities: ["local_files", "markdown_edit", "asset_upload"],
}, null, 2)}\n`,
"utf8",
);
}
function createLocalFixture() {
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task801-image-preview-"));
const root = path.join(dataRoot, "workspace");
fs.mkdirSync(root, { recursive: true });
writeWorkspaceManifest(root, ACTOR_ID);
fs.writeFileSync(
path.join(root, RELATIVE_PATH),
["---", "title: Image Preview", "---", "", "# Image Preview", "", "初始正文", ""].join("\n"),
"utf8",
);
return { dataRoot, root, relativePath: RELATIVE_PATH, documentId: localMdDocumentId(RELATIVE_PATH) };
}
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
async function waitForServer(baseUrl) {
const deadline = Date.now() + SERVER_TIMEOUT_MS;
let lastError = null;
while (Date.now() < deadline) {
try {
const response = await fetchWithTimeout(`${baseUrl}/health`);
if (response.status >= 200 && response.status < 500) return;
lastError = new Error(`server_not_ready_${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw lastError || new Error(`server_not_ready: ${baseUrl}`);
}
async function startLocalServer(dataRoot) {
if (BASE_URL) return null;
const port = await pickPort();
BASE_URL = `http://127.0.0.1:${port}`;
const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(__dirname, "..", "rust"),
env: {
...process.env,
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
try {
await waitForServer(BASE_URL);
} catch (error) {
server.kill("SIGTERM");
throw new Error(`${error.message}\n${stderr.slice(-3000)}`);
}
return server;
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
}, null, { timeout: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
async function setImageFixture(page) {
await page.evaluate((imageSrc) => {
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
if (!editor) throw new Error("找不到 Tiptap editor");
editor.commands.setContent({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "task801 image preview fixture" }] },
{ type: "image", attrs: { src: imageSrc, alt: "E24 图片占位", title: "E24 图片", "data-align": "center" } },
],
}, true);
editor.commands.focus("start");
}, IMAGE_SRC);
await page.waitForFunction((imageSrc) => {
const image = document.querySelector(`.editor-surface .ProseMirror img[src="${imageSrc}"]`);
return image instanceof HTMLImageElement && image.naturalWidth > 0 && image.naturalHeight > 0;
}, IMAGE_SRC, { timeout: UI_TIMEOUT_MS });
}
async function main() {
assertImagePreviewSourceBoundary();
const target = createLocalFixture();
const server = await startLocalServer(target.dataRoot);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
acceptDownloads: true,
extraHTTPHeaders: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
try {
const url = documentUrl(target.root, target.relativePath);
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
const editor = await waitForRuntimeIsland(page);
await setImageFixture(page);
const beforeUrl = page.url();
const beforeLayout = await page.evaluate(() => {
const sidebar = document.querySelector(".wolai-sidebar, .mnote-sidebar");
const content = document.querySelector(".mnote-content, .document-workspace");
const sidebarRect = sidebar?.getBoundingClientRect();
const contentRect = content?.getBoundingClientRect();
return {
sidebarLeft: sidebarRect?.left ?? null,
sidebarTop: sidebarRect?.top ?? null,
contentLeft: contentRect?.left ?? null,
contentTop: contentRect?.top ?? null,
};
});
const image = page.locator('.editor-surface .ProseMirror img[src]').first();
await image.dblclick({ timeout: UI_TIMEOUT_MS });
const dialog = page.locator('[data-testid="image-preview-dialog"]').first();
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await screenshot(page, "01-image-preview-open");
assert.equal(page.url(), beforeUrl, "双击图片打开预览不应改变 URL");
assert.equal(await dialog.getAttribute("role"), "dialog", "图片预览应暴露 dialog 语义");
assert.equal(await dialog.getAttribute("aria-modal"), "true", "图片预览应是 modal");
assert.equal(await dialog.getAttribute("data-preview-index"), "0", "单图预览 index 应为 0");
assert.equal(await dialog.getAttribute("data-preview-total"), "1", "单图预览 total 应为 1");
assert.equal(await dialog.locator('[data-testid="image-preview-image"]').first().getAttribute("src"), IMAGE_SRC, "预览图 src 应保持原图");
assert.equal(await dialog.evaluate((node) => Boolean(node.closest('[data-testid="mnote-leptos-tiptap-editor-stage"]'))), false, "预览 dialog 不能挂在 editor-stage 内,否则 fixed 坐标会被外层布局/transform 改写");
const dialogRect = await dialog.evaluate((node) => {
const rect = node.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
});
assert.ok(Math.abs(dialogRect.left) <= 1 && Math.abs(dialogRect.top) <= 1, `预览遮罩必须从视口左上角开始: ${JSON.stringify(dialogRect)}`);
assert.ok(Math.abs(dialogRect.width - dialogRect.viewportWidth) <= 1, `预览遮罩必须覆盖完整视口宽度: ${JSON.stringify(dialogRect)}`);
assert.ok(Math.abs(dialogRect.height - dialogRect.viewportHeight) <= 1, `预览遮罩必须覆盖完整视口高度: ${JSON.stringify(dialogRect)}`);
for (const testid of [
"image-preview-close",
"image-preview-prev",
"image-preview-next",
"image-preview-zoom-in",
"image-preview-zoom-out",
"image-preview-one-to-one",
"image-preview-rotate",
"image-preview-download",
"image-preview-fullscreen",
]) {
await dialog.locator(`[data-testid="${testid}"]`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
assert.equal(await dialog.locator('[data-testid="image-preview-prev"]').first().isDisabled(), true, "单图预览上一张应禁用");
assert.equal(await dialog.locator('[data-testid="image-preview-next"]').first().isDisabled(), true, "单图预览下一张应禁用");
await dialog.locator('[data-testid="image-preview-zoom-in"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-zoom") === "1.25", null, { timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-rotate"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-rotation") === "90", null, { timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-one-to-one"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const node = document.querySelector('[data-testid="image-preview-dialog"]');
return node?.getAttribute("data-preview-zoom") === "1"
&& node?.getAttribute("data-preview-rotation") === "0"
&& node?.getAttribute("data-preview-offset-x") === "0"
&& node?.getAttribute("data-preview-offset-y") === "0";
}, null, { timeout: UI_TIMEOUT_MS });
const stageBox = await dialog.locator('[data-testid="image-preview-stage"]').first().boundingBox();
assert.ok(stageBox, "预览 stage 应有可交互区域");
const stageCenter = {
x: stageBox.x + stageBox.width / 2,
y: stageBox.y + stageBox.height / 2,
};
await page.mouse.move(stageCenter.x, stageCenter.y);
await page.mouse.wheel(0, -240);
await page.waitForFunction(() => Number(document.querySelector('[data-testid="image-preview-dialog"]')?.getAttribute("data-preview-zoom") || "1") > 1, null, { timeout: UI_TIMEOUT_MS });
await page.mouse.down({ button: "middle" });
await page.mouse.move(stageCenter.x + 72, stageCenter.y + 38, { steps: 6 });
await page.mouse.up({ button: "middle" });
await page.waitForFunction(() => {
const node = document.querySelector('[data-testid="image-preview-dialog"]');
return Math.abs(Number(node?.getAttribute("data-preview-offset-x") || "0")) >= 60
&& Math.abs(Number(node?.getAttribute("data-preview-offset-y") || "0")) >= 30;
}, null, { timeout: UI_TIMEOUT_MS });
const afterLayout = await page.evaluate(() => {
const sidebar = document.querySelector(".wolai-sidebar, .mnote-sidebar");
const content = document.querySelector(".mnote-content, .document-workspace");
const sidebarRect = sidebar?.getBoundingClientRect();
const contentRect = content?.getBoundingClientRect();
return {
sidebarLeft: sidebarRect?.left ?? null,
sidebarTop: sidebarRect?.top ?? null,
contentLeft: contentRect?.left ?? null,
contentTop: contentRect?.top ?? null,
};
});
for (const key of ["sidebarLeft", "sidebarTop", "contentLeft", "contentTop"]) {
if (beforeLayout[key] === null || afterLayout[key] === null) continue;
assert.ok(Math.abs(beforeLayout[key] - afterLayout[key]) <= 1, `预览交互不应导致左侧框架/正文布局漂移 ${key}: before=${beforeLayout[key]} after=${afterLayout[key]}`);
}
const downloadPromise = page.waitForEvent("download", { timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-download"]').first().click({ timeout: UI_TIMEOUT_MS });
const download = await downloadPromise;
assert(/E24.*\.svg$/i.test(download.suggestedFilename()), `图片预览下载文件名异常: ${download.suggestedFilename()}`);
await download.delete().catch(() => undefined);
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await image.dblclick({ timeout: UI_TIMEOUT_MS });
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dialog.locator('[data-testid="image-preview-close"]').first().click({ timeout: UI_TIMEOUT_MS });
await dialog.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await editor.click({ timeout: UI_TIMEOUT_MS });
assert.equal(await page.locator('[data-testid="image-preview-dialog"]').count(), 0, "关闭预览后不应残留 dialog");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, root: target.root, screenshotDir: SCREENSHOT_DIR }, null, 2));
} finally {
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
if (server?.pid) {
server.kill("SIGTERM");
setTimeout(() => {
if (!server.killed) server.kill("SIGKILL");
}, 2000).unref();
}
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,198 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_WOLAI_SEARCH_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
const WORKSPACE_ID = process.env.MNOTE_WOLAI_SEARCH_SMOKE_WORKSPACE_ID
|| "ws_c23b7696e2b64e4fb1a2b12761af4cfd";
const DOCUMENT_RELATIVE_PATH = process.env.MNOTE_WOLAI_SEARCH_SMOKE_DOCUMENT
|| "liaibo的个人空间/知识/实验资料-工具书/核磁常见杂质化学位移表(核磁溶剂峰)/核磁常见杂质化学位移表(核磁溶剂峰).md";
const QUERY = process.env.MNOTE_WOLAI_SEARCH_SMOKE_QUERY || "核磁溶剂峰";
const EXPECTED_TITLE = process.env.MNOTE_WOLAI_SEARCH_SMOKE_EXPECTED_TITLE
|| "核磁常见杂质化学位移表(核磁溶剂峰)";
const USERNAME = process.env.MNOTE_WOLAI_SEARCH_SMOKE_USER || "liaibo";
const PASSWORD = process.env.MNOTE_WOLAI_SEARCH_SMOKE_PASSWORD || "MnoteE2E123!";
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
const OUT_DIR = path.join(process.cwd(), "tmp", "task801-local-search-default-smoke");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUT_DIR, "local-search-default.png");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function encodeLocalIdSegment(value) {
const bytes = Buffer.from(String(value || ""), "utf8");
let encoded = "";
for (const byte of bytes) {
const character = String.fromCharCode(byte);
if (
(byte >= 48 && byte <= 57)
|| (byte >= 65 && byte <= 90)
|| (byte >= 97 && byte <= 122)
|| character === "."
|| character === "_"
|| character === "-"
) {
encoded += character;
} else {
encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
}
return encoded;
}
function documentUrl() {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(`local-md:${encodeLocalIdSegment(DOCUMENT_RELATIVE_PATH)}`)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
url.searchParams.set("workspaceId", WORKSPACE_ID);
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function signIn(request) {
const response = await request.post(`${BASE_URL}/api/auth`, {
headers: { "content-type": "application/json", accept: "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: USERNAME,
password: PASSWORD,
flow: "signIn",
},
},
},
});
assert.equal(response.status(), 200, `登录失败: ${response.status()} ${await response.text()}`);
const whoami = await (await request.get(`${BASE_URL}/api/auth/whoami`, {
headers: { accept: "application/json" },
})).json();
assert.equal(whoami.userId, USERNAME);
return whoami;
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const settingsPath = path.join(ROOT_PATH, ".mnote", "index", "local-index-settings.json");
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
assert.deepEqual(settings.includePaths, ["."], "local-first 工作区默认应索引整个目录");
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
});
const page = await context.newPage();
page.setDefaultTimeout(UI_TIMEOUT_MS);
try {
const whoami = await signIn(context.request);
const apiSearch = await context.request.post(`${BASE_URL}/api/search/documents`, {
headers: { "content-type": "application/json", accept: "application/json" },
data: {
workspaceId: WORKSPACE_ID,
sourceKind: "local_folder",
rootUri: fileUrl(ROOT_PATH),
documentId: `local-md:${encodeLocalIdSegment(DOCUMENT_RELATIVE_PATH)}`,
query: QUERY,
limit: 30,
filters: {
titleOnly: false,
exact: false,
includeOcr: false,
onlyCurrentPage: false,
timeRange: "any",
timeField: "updated",
},
},
});
assert.equal(apiSearch.status(), 200, `本地搜索 API 失败: ${apiSearch.status()} ${await apiSearch.text()}`);
const apiPayload = await apiSearch.json();
assert.ok(
(apiPayload.results || []).some((item) => String(item.title || "").includes(EXPECTED_TITLE)),
`本地搜索 API 未命中目标页: ${JSON.stringify(apiPayload.results?.slice(0, 5), null, 2)}`,
);
await page.goto(documentUrl(), { waitUntil: "domcontentloaded" });
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ state: "visible" });
await page.keyboard.press("Control+P");
const modal = page.locator('[data-testid="wolai-search-modal"]');
await modal.waitFor({ state: "visible" });
const input = modal.locator('[data-testid="wolai-search-input"]');
const knowledgeSwitch = modal.locator('[data-search-switch="knowledge"]');
assert.equal(await knowledgeSwitch.getAttribute("aria-checked"), "false", "搜索默认不应走空的全盘知识库");
await input.fill(QUERY);
await page.waitForFunction(
(expectedTitle) => {
const meta = document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "";
const rows = Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'));
return meta.includes("工作区搜索")
&& /共\s*[1-9]\d*\s*条匹配结果/.test(meta)
&& rows.some((row) => (row.textContent || "").includes(expectedTitle));
},
EXPECTED_TITLE,
{ timeout: UI_TIMEOUT_MS },
);
const state = await page.evaluate((expectedTitle) => {
const rows = Array.from(document.querySelectorAll('[data-testid="wolai-search-result-row"]'));
return {
knowledgeChecked: document.querySelector('[data-search-switch="knowledge"]')?.getAttribute("aria-checked") || "",
meta: document.querySelector('[data-testid="wolai-search-result-meta"]')?.textContent || "",
rowCount: rows.length,
targetFound: rows.some((row) => (row.textContent || "").includes(expectedTitle)),
firstRows: rows.slice(0, 5).map((row) => row.textContent || ""),
};
}, EXPECTED_TITLE);
assert.equal(state.knowledgeChecked, "false", `全盘知识库默认状态被改回开启: ${JSON.stringify(state)}`);
assert.ok(state.meta.includes("工作区搜索"), `搜索 UI 未走工作区搜索: ${JSON.stringify(state)}`);
assert.ok(state.rowCount > 0, `搜索 UI 未返回结果: ${JSON.stringify(state)}`);
assert.equal(state.targetFound, true, `搜索 UI 未展示目标页: ${JSON.stringify(state, null, 2)}`);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: false });
const result = {
ok: true,
whoami,
rootPath: ROOT_PATH,
workspaceId: WORKSPACE_ID,
query: QUERY,
expectedTitle: EXPECTED_TITLE,
apiCount: apiPayload.results?.length || 0,
state,
screenshotPath: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close();
}
}
main().catch((error) => {
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(
path.join(OUT_DIR, "failure.json"),
`${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`,
"utf8",
);
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,218 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { request } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_WOLAI_PDF_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
const LIMIT = Number(process.env.MNOTE_WOLAI_PDF_SMOKE_LIMIT || 10);
const USERNAME = process.env.MNOTE_WOLAI_PDF_SMOKE_USER || "mnote.e2e@example.com";
const PASSWORD = process.env.MNOTE_WOLAI_PDF_SMOKE_PASSWORD || "MnoteE2E123!";
const OUT_DIR = path.join(process.cwd(), "tmp", "task802-pdf-attachments-restore-smoke");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
function fileUrl(localPath) {
return `file://${localPath}`;
}
function encodeLocalIdSegment(value) {
const bytes = Buffer.from(String(value || ""), "utf8");
let encoded = "";
for (const byte of bytes) {
const character = String.fromCharCode(byte);
if (
(byte >= 48 && byte <= 57)
|| (byte >= 65 && byte <= 90)
|| (byte >= 97 && byte <= 122)
|| character === "."
|| character === "_"
|| character === "-"
) {
encoded += character;
} else {
encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
}
return encoded;
}
function markdownDocumentId(relativePath) {
return `local-md:${encodeLocalIdSegment(relativePath)}`;
}
function walkMarkdownFiles(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === ".assets" || entry.name === ".git" || entry.name === "node_modules") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkMarkdownFiles(fullPath, out);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
out.push(fullPath);
}
}
return out;
}
function rootRelativePath(filePath) {
return path.relative(ROOT_PATH, filePath).split(path.sep).join("/");
}
function normalizeHref(href) {
return String(href || "")
.trim()
.replace(/^<|>$/g, "")
.replace(/\\([\\`*_{}\[\]()#+\-.!|>])/g, "$1");
}
function pdfHrefs(markdown) {
const refs = [];
const source = String(markdown || "");
const linkPattern = /\[[^\]]*?\.pdf[^\]]*?\]\(\s*(?:<([^>]+?\.pdf)>|([^)\s]+?\.pdf))(?:\s+["'][^"']*["'])?\s*\)/gi;
for (const match of source.matchAll(linkPattern)) {
const href = normalizeHref(match[1] || match[2] || "");
if (!href || /^https?:\/\//i.test(href) || href.startsWith("#") || href.startsWith("mailto:")) {
continue;
}
refs.push(href);
}
return refs;
}
function collectExistingPdfAttachments() {
const candidates = [];
for (const markdownPath of walkMarkdownFiles(ROOT_PATH)) {
const markdown = fs.readFileSync(markdownPath, "utf8");
const markdownRelativePath = rootRelativePath(markdownPath);
for (const href of pdfHrefs(markdown)) {
if (!href.includes(".assets/")) {
continue;
}
const assetRelativePath = path.posix.normalize(path.posix.join(
path.posix.dirname(markdownRelativePath),
href,
));
const assetPath = path.join(ROOT_PATH, ...assetRelativePath.split("/"));
if (!fs.existsSync(assetPath)) {
continue;
}
const bytes = fs.statSync(assetPath).size;
if (bytes <= 0) {
continue;
}
candidates.push({
markdownRelativePath,
href,
assetRelativePath,
assetPath,
bytes,
});
if (candidates.length >= LIMIT) {
return candidates;
}
}
}
return candidates;
}
async function signIn(api) {
const response = await api.post("/api/auth", {
headers: { "content-type": "application/json", accept: "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: USERNAME,
password: PASSWORD,
flow: "signIn",
},
},
},
});
assert.equal(response.status(), 200, `登录失败: ${response.status()} ${await response.text()}`);
}
function flattenBlockDocumentBlocks(blockDocument) {
const direct = Array.isArray(blockDocument?.blocks) ? blockDocument.blocks : [];
return direct;
}
function pageHasMediaSource(pageAggregate, href, assetRelativePath) {
const content = Array.isArray(pageAggregate?.body?.content) ? pageAggregate.body.content : [];
if (content.some((block) => block?.type === "media" && block?.props?.sourcePath === href)) {
return true;
}
const blocks = flattenBlockDocumentBlocks(pageAggregate?.body?.blockDocument);
return blocks.some((block) => (
block?.type === "media"
&& (block?.attrs?.sourcePath === href || block?.attrs?.sourcePath === assetRelativePath)
));
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const candidates = collectExistingPdfAttachments();
assert.ok(candidates.length >= Math.min(5, LIMIT), `可验证 PDF 附件不足: ${candidates.length}`);
const api = await request.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: { accept: "application/json" },
});
try {
await signIn(api);
const checked = [];
for (const candidate of candidates) {
const documentId = markdownDocumentId(candidate.markdownRelativePath);
const aggregateUrl = new URL(`/api/page-aggregate/${encodeURIComponent(documentId)}`, BASE_URL);
aggregateUrl.searchParams.set("sourceKind", "local_folder");
aggregateUrl.searchParams.set("rootUri", fileUrl(ROOT_PATH));
const response = await api.get(`${aggregateUrl.pathname}${aggregateUrl.search}`);
assert.equal(
response.status(),
200,
`page aggregate 失败 ${candidate.markdownRelativePath}: ${response.status()} ${await response.text()}`,
);
const payload = await response.json();
const aggregate = payload.result;
const attachmentRefs = Array.isArray(aggregate?.body?.attachmentRefs)
? aggregate.body.attachmentRefs
: [];
const ref = attachmentRefs.find((item) => (
item?.relativePath === candidate.assetRelativePath
|| item?.normalizedHref === candidate.href
|| item?.rawHref === candidate.href
));
assert.ok(ref, `aggregate 缺少 PDF attachmentRef: ${candidate.assetRelativePath}`);
assert.equal(String(ref.ext || "").toLowerCase(), "pdf", JSON.stringify(ref));
assert.equal(ref.exists, true, JSON.stringify(ref));
assert.equal(ref.authorized, true, JSON.stringify(ref));
assert.equal(ref.fileSize, candidate.bytes, JSON.stringify({ ref, candidate }));
assert.ok(
pageHasMediaSource(aggregate, candidate.href, candidate.assetRelativePath),
`aggregate 未还原 PDF media block: ${candidate.assetRelativePath}`,
);
checked.push({
markdownRelativePath: candidate.markdownRelativePath,
assetRelativePath: candidate.assetRelativePath,
bytes: candidate.bytes,
label: ref.label,
});
}
const result = { ok: true, checked };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await api.dispose();
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,287 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const ROOT_PATH = process.env.MNOTE_WOLAI_PAGE_REF_SMOKE_ROOT
|| "/mnt/Data1T/Mnote_data/users/liaibo/workspaces/my-space";
const PARENT_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_PARENT
|| "liaibo的个人空间/项目/项目.md";
const CHILD_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_CHILD
|| "liaibo的个人空间/项目/完结项目/完结项目.md";
const GRANDCHILD_PAGE = process.env.MNOTE_WOLAI_PAGE_REF_GRANDCHILD
|| "liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md";
const USERNAME = process.env.MNOTE_WOLAI_PAGE_REF_USER || "mnote.e2e@example.com";
const PASSWORD = process.env.MNOTE_WOLAI_PAGE_REF_PASSWORD || "MnoteE2E123!";
const OUT_DIR = path.join(process.cwd(), "tmp", "task803-local-page-reference-restore-smoke");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
function fileUrl(localPath) {
return `file://${localPath}`;
}
function encodeLocalIdSegment(value) {
const bytes = Buffer.from(String(value || ""), "utf8");
let encoded = "";
for (const byte of bytes) {
const character = String.fromCharCode(byte);
if (
(byte >= 48 && byte <= 57)
|| (byte >= 65 && byte <= 90)
|| (byte >= 97 && byte <= 122)
|| character === "."
|| character === "_"
|| character === "-"
) {
encoded += character;
} else {
encoded += `~${byte.toString(16).toUpperCase().padStart(2, "0")}`;
}
}
return encoded;
}
function localMdDocumentId(relativePath) {
return `local-md:${encodeLocalIdSegment(relativePath)}`;
}
function documentUrl(relativePath) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(ROOT_PATH));
url.searchParams.set("treeView", "filetree");
return url.toString();
}
async function signIn(request) {
const response = await request.post(`${BASE_URL}/api/auth`, {
headers: { "content-type": "application/json", accept: "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: USERNAME,
password: PASSWORD,
flow: "signIn",
},
},
},
});
assert.equal(response.status(), 200, `登录失败: ${response.status()} ${await response.text()}`);
}
async function waitForEditor(page) {
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first()
.waitFor({ state: "visible" });
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first()
.waitFor({ state: "visible" });
}
async function pageReferenceTexts(page) {
return page.evaluate(() => Array.from(document.querySelectorAll(".editor-surface .ProseMirror a.mnote-page-block-link"))
.map((anchor) => ({
text: (anchor.textContent || "").trim(),
href: anchor.getAttribute("href") || "",
className: anchor.className || "",
target: anchor.getAttribute("target") || "",
})));
}
async function pageBlockVisual(page, title) {
return page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: title }).first().evaluate((anchor) => {
const style = window.getComputedStyle(anchor);
const before = window.getComputedStyle(anchor, "::before");
const row = anchor.closest("p");
const rowStyle = row ? window.getComputedStyle(row) : null;
return {
text: anchor.textContent || "",
display: style.display,
color: style.color,
fontWeight: style.fontWeight,
textDecorationLine: style.textDecorationLine,
beforeWidth: before.width,
beforeHeight: before.height,
rowBackground: rowStyle ? rowStyle.backgroundColor : "",
};
});
}
function assertWolaiPageBlockVisual(visual) {
assert.equal(visual.display, "inline-flex", `页面块应保持图标+标题的 inline-flex 形态: ${JSON.stringify(visual)}`);
assert.notEqual(visual.color, "rgb(0, 0, 238)", `页面块不能退化为浏览器默认蓝色链接: ${JSON.stringify(visual)}`);
assert.ok(visual.textDecorationLine.includes("underline"), `页面块标题应保留 Wolai 风格下划线: ${JSON.stringify(visual)}`);
assert.ok(parseFloat(visual.beforeWidth) >= 14 && parseFloat(visual.beforeHeight) >= 14, `页面块必须有独立页面图标: ${JSON.stringify(visual)}`);
assert.ok(
["", "rgba(0, 0, 0, 0)", "transparent"].includes(visual.rowBackground),
`页面块常态不能渲染整行浅红背景: ${JSON.stringify(visual)}`,
);
}
function installMutationProbe(page) {
return page.evaluate(() => {
if (window.__mnoteTask803MutationObserver) {
window.__mnoteTask803MutationObserver.disconnect();
}
const countElementNodes = (node) => {
if (!(node instanceof Element)) return 0;
return 1 + node.querySelectorAll("*").length;
};
const roots = [
["sidebar", document.querySelector(".mnote-sidebar, #sidebar-file-tree-root, #sidebar-tree-root")],
["workspace", document.querySelector("[data-testid='mnote-document-workspace'], .document-workspace")],
].filter((entry) => entry[1] instanceof Element);
window.__mnoteTask803MutationProbe = {
navigationEntries: performance.getEntriesByType("navigation").length,
records: [],
};
const observer = new MutationObserver((records) => {
for (const record of records) {
const owner = roots.find((entry) => entry[1].contains(record.target));
window.__mnoteTask803MutationProbe.records.push({
owner: owner ? owner[0] : "unknown",
added: Array.from(record.addedNodes).reduce((sum, node) => sum + countElementNodes(node), 0),
removed: Array.from(record.removedNodes).reduce((sum, node) => sum + countElementNodes(node), 0),
});
}
});
roots.forEach((entry) => observer.observe(entry[1], { childList: true, subtree: true }));
window.__mnoteTask803MutationObserver = observer;
});
}
function readMutationProbe(page) {
return page.evaluate(() => {
if (window.__mnoteTask803MutationObserver) {
window.__mnoteTask803MutationObserver.disconnect();
}
const probe = window.__mnoteTask803MutationProbe || { navigationEntries: 0, records: [] };
const summary = {
navigationEntriesBefore: probe.navigationEntries,
navigationEntriesAfter: performance.getEntriesByType("navigation").length,
sidebarAdded: 0,
sidebarRemoved: 0,
workspaceAdded: 0,
workspaceRemoved: 0,
};
for (const record of probe.records || []) {
if (record.owner === "sidebar") {
summary.sidebarAdded += record.added || 0;
summary.sidebarRemoved += record.removed || 0;
}
if (record.owner === "workspace") {
summary.workspaceAdded += record.added || 0;
summary.workspaceRemoved += record.removed || 0;
}
}
return summary;
});
}
function assertLocalPageHref(item, expectedRelativePath) {
const url = new URL(item.href, BASE_URL);
assert.ok(
decodeURIComponent(url.pathname).includes(localMdDocumentId(expectedRelativePath)),
`页面块 href 未指向目标页面: ${JSON.stringify({ item, expectedRelativePath })}`,
);
assert.equal(url.searchParams.get("sourceKind"), "local_folder", JSON.stringify(item));
assert.equal(url.searchParams.get("rootUri"), fileUrl(ROOT_PATH), JSON.stringify(item));
assert.equal(item.target, "_self", JSON.stringify(item));
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
locale: "zh-CN",
});
const page = await context.newPage();
page.setDefaultTimeout(Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000));
const clickRequests = [];
let collectClickRequests = false;
page.on("request", (request) => {
if (!collectClickRequests) return;
clickRequests.push({
method: request.method(),
resourceType: request.resourceType(),
url: request.url(),
});
});
try {
await signIn(context.request);
await page.goto(documentUrl(PARENT_PAGE), { waitUntil: "domcontentloaded" });
await waitForEditor(page);
const parentRefs = await pageReferenceTexts(page);
const completeProjectRef = parentRefs.find((item) => item.text === "完结项目");
assert.ok(completeProjectRef, `父页面没有把 Markdown 子页面链接还原为页面块: ${JSON.stringify(parentRefs)}`);
assertLocalPageHref(completeProjectRef, CHILD_PAGE);
const parentPageBlockVisual = await pageBlockVisual(page, "完结项目");
assertWolaiPageBlockVisual(parentPageBlockVisual);
await page.screenshot({ path: path.join(OUT_DIR, "01-parent-page-reference.png"), fullPage: false });
await installMutationProbe(page);
collectClickRequests = true;
await page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: "完结项目" }).first().click();
await page.waitForURL((url) => decodeURIComponent(url.pathname).includes(localMdDocumentId(CHILD_PAGE)));
await waitForEditor(page);
collectClickRequests = false;
const firstClickProbe = await readMutationProbe(page);
const firstClickDocumentRequests = clickRequests.filter((request) => request.resourceType === "document");
const firstClickTreeProjectionRequests = clickRequests.filter((request) => request.url.includes("/api/tree/projections/"));
assert.equal(firstClickDocumentRequests.length, 0, `页面块点击不能触发整页 document 导航: ${JSON.stringify(firstClickDocumentRequests)}`);
assert.equal(firstClickTreeProjectionRequests.length, 0, `页面块点击不应重拉树 projection: ${JSON.stringify(firstClickTreeProjectionRequests)}`);
assert.equal(firstClickProbe.navigationEntriesAfter, firstClickProbe.navigationEntriesBefore, `页面块点击不能新增浏览器 navigation entry: ${JSON.stringify(firstClickProbe)}`);
assert.ok(firstClickProbe.sidebarAdded + firstClickProbe.sidebarRemoved <= 4, `页面块点击不应重建左侧 Sidebar DOM: ${JSON.stringify(firstClickProbe)}`);
const childRefs = await pageReferenceTexts(page);
const childTitles = childRefs.map((item) => item.text);
assert.ok(childTitles.includes("爱斯特完结项目"), `子页面纯文本行没有推断为页面块: ${JSON.stringify(childRefs)}`);
assert.ok(childTitles.includes("药友完结项目"), `子页面缺少其它同级页面块: ${JSON.stringify(childRefs)}`);
assert.ok(childTitles.includes("倍特完结项目"), `子页面缺少其它同级页面块: ${JSON.stringify(childRefs)}`);
assert.ok(!childTitles.includes("22"), `普通文本不应被误还原为页面块: ${JSON.stringify(childRefs)}`);
const grandchildRef = childRefs.find((item) => item.text === "爱斯特完结项目");
assertLocalPageHref(grandchildRef, GRANDCHILD_PAGE);
const childPageBlockVisual = await pageBlockVisual(page, "爱斯特完结项目");
assertWolaiPageBlockVisual(childPageBlockVisual);
await page.screenshot({ path: path.join(OUT_DIR, "02-child-inferred-page-references.png"), fullPage: false });
await page.locator(".editor-surface .ProseMirror a.mnote-page-block-link", { hasText: "爱斯特完结项目" }).first().click();
await page.waitForURL((url) => decodeURIComponent(url.pathname).includes(localMdDocumentId(GRANDCHILD_PAGE)));
await waitForEditor(page);
await page.screenshot({ path: path.join(OUT_DIR, "03-click-opened-grandchild.png"), fullPage: false });
const result = {
ok: true,
parentDocumentId: localMdDocumentId(PARENT_PAGE),
childDocumentId: localMdDocumentId(CHILD_PAGE),
grandchildDocumentId: localMdDocumentId(GRANDCHILD_PAGE),
firstClickProbe,
screenshots: [
path.join(OUT_DIR, "01-parent-page-reference.png"),
path.join(OUT_DIR, "02-child-inferred-page-references.png"),
path.join(OUT_DIR, "03-click-opened-grandchild.png"),
],
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
});
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { importWolaiExport } = require("./lib/wolai-export-importer");
function writeUtf8(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
}
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "mnote-wolai-import-smoke-"));
}
function runImportFixture() {
const workspace = makeTempDir();
const sourceRoot = path.join(workspace, "source");
const targetRoot = path.join(workspace, "target");
const nbsp = "\u00A0";
const assetName = `12849总结报告-WB17061405${nbsp}肖建洋_qGouWUbkM_.doc`;
writeUtf8(
path.join(sourceRoot, "liaibo的个人空间.md"),
[
"# liaibo的个人空间",
"",
"- [项目](pages/项目_abcd1234.md)",
"",
].join("\n"),
);
writeUtf8(
path.join(sourceRoot, "pages", "项目_abcd1234.md"),
[
"# 项目",
"",
"![](../image/diagram one.png)",
`[报告](../file/${assetName} "12849总结报告-WB17061405${nbsp}肖建洋.doc")`,
"[/home/fc/import/markdown/abc/resources/s0040-4020(01)88151-5.pdf](/home/fc/import/markdown/abc/resources/s0040-4020\\(01\\)88151-5.pdf)",
"[旧resources附件](../../resources/林可函数.xlsx \"林可函数.xlsx\")",
"",
].join("\n"),
);
writeUtf8(path.join(sourceRoot, "image", "diagram one.png"), "png-body\n");
writeUtf8(path.join(sourceRoot, "file", assetName), "doc-body\n");
const result = importWolaiExport({
sourceRoot,
targetRoot,
ownerId: "liaibo",
workspaceId: "local-ws:liaibo:my-space",
copyMode: "copy",
});
return {
result,
targetRoot,
assetName,
childMarkdownPath: path.join(
targetRoot,
"liaibo的个人空间",
"项目",
"项目.md",
),
};
}
function assertWolaiExportImport() {
const { result, targetRoot, assetName, childMarkdownPath } = runImportFixture();
const childMarkdown = fs.readFileSync(childMarkdownPath, "utf8");
const workspaceMetadata = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "workspace.json"), "utf8"),
);
const importMetadata = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "wolai-import.json"), "utf8"),
);
const localIndexSettings = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "index", "local-index-settings.json"), "utf8"),
);
assert.equal(result.ok, true);
assert.equal(result.pages.imported, 2);
assert.equal(result.assets.copied, 2);
assert.equal(result.links.rewritten, 5);
assert.equal(result.links.missing, 2);
assert.equal(workspaceMetadata.ownerId, "liaibo");
assert.equal(importMetadata.links.missingSamples.length, 2);
assert.deepEqual(localIndexSettings.includePaths, ["."], "导入后默认应索引整个 local-first 工作区");
assert.equal(localIndexSettings.runOnChange, false);
assert.match(childMarkdown, /\.assets\/image\/diagram one\.png/);
assert.match(childMarkdown, /\.assets\/file\/12849总结报告-WB17061405/);
assert.doesNotMatch(childMarkdown, /Wolai资源/);
assert.doesNotMatch(childMarkdown, /\]\(\.\.\/file\//);
assert.doesNotMatch(childMarkdown, /\/home\/fc\/import/);
assert.doesNotMatch(childMarkdown, /resources\/林可函数\.xlsx/);
assert.match(childMarkdown, /#wolai-missing-resource/);
assert.equal(
fs.readFileSync(
path.join(targetRoot, "liaibo的个人空间", "项目", ".assets", "image", "diagram one.png"),
"utf8",
),
"png-body\n",
);
assert.equal(
fs.readFileSync(
path.join(targetRoot, "liaibo的个人空间", "项目", ".assets", "file", assetName),
"utf8",
),
"doc-body\n",
);
const uploadedAssets = JSON.parse(
fs.readFileSync(path.join(targetRoot, ".mnote", "uploaded-assets.json"), "utf8"),
);
assert.ok(uploadedAssets.entries["liaibo的个人空间/项目/.assets/image/diagram one.png"]);
assert.ok(uploadedAssets.entries[`liaibo的个人空间/项目/.assets/file/${assetName}`]);
}
assertWolaiExportImport();
console.log("wolai-export-importer.test: ok");