推进本地索引与 AI changed-files 审计
- 为本地工作区补充 search/backlinks/tags/resource 引用索引与 watcher 单文件刷新 - 在页面设置中增加索引页签,并展示本地反链、标签和 AI changed-files 审计 - 补充本地搜索与本地 AI changed-files 浏览器 smoke,并回填当前优先级 checklist
This commit is contained in:
+32
-25
@@ -118,7 +118,7 @@
|
|||||||
|
|
||||||
补充验证:
|
补充验证:
|
||||||
|
|
||||||
- [x] `cargo fmt --check --all`
|
- [x] `cargo fmt --check --all --manifest-path rust/Cargo.toml`
|
||||||
- [x] `cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`
|
- [x] `cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`
|
||||||
- [x] `cargo test -p mnote-web local_folder -- --nocapture`
|
- [x] `cargo test -p mnote-web local_folder -- --nocapture`
|
||||||
- [x] `npm run check:local-first-convex-guard`
|
- [x] `npm run check:local-first-convex-guard`
|
||||||
@@ -146,8 +146,9 @@
|
|||||||
- [x] run 结束后对比 root snapshot,生成 changed files。
|
- [x] run 结束后对比 root snapshot,生成 changed files。
|
||||||
- [x] 对 markdown 文件生成简短 diff summary;大文件只记录 hash / size / path。
|
- [x] 对 markdown 文件生成简短 diff summary;大文件只记录 hash / size / path。
|
||||||
- [x] 把审计事件落盘到 control-plane,不写进用户正文目录。
|
- [x] 把审计事件落盘到 control-plane,不写进用户正文目录。
|
||||||
- [ ] 在 AI 会话 UI 显示 changed files 列表,并可展开查看 diff 摘要。
|
- [x] 在 AI 会话 UI 显示 changed files 列表,并可展开查看 diff 摘要。
|
||||||
- 已接入:`run.completed.agentAudit.changedFiles` 会进入 `agent.changed_files` 工具卡并可展开查看;待真实浏览器 AI run smoke 验证后勾选。
|
- 实现:`run.completed.agentAudit.changedFiles` 会进入 `agent.changed_files` 工具卡并显示 changed files 与 diff summary。
|
||||||
|
- 验证:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task453-local-folder-page-ai-changed-files-smoke.js`
|
||||||
|
|
||||||
建议验证:
|
建议验证:
|
||||||
|
|
||||||
@@ -157,9 +158,8 @@
|
|||||||
- 验证:`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read -- --nocapture`
|
- 验证:`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read -- --nocapture`
|
||||||
- [x] 单测:MNote tool 本地写入归入同一 run audit,并标记 `origin=mnote_tool`。
|
- [x] 单测:MNote tool 本地写入归入同一 run audit,并标记 `origin=mnote_tool`。
|
||||||
- 验证:`cargo test -p mnote-web hermes_tools_page_save_local_folder_writes_markdown_file -- --nocapture`
|
- 验证:`cargo test -p mnote-web hermes_tools_page_save_local_folder_writes_markdown_file -- --nocapture`
|
||||||
- [ ] browser smoke:AI 修改一篇本地 markdown 后,会话面板显示 changed files。
|
- [x] browser smoke:AI 修改一篇本地 markdown 后,会话面板显示 changed files。
|
||||||
- 已验证 UI 展示链路:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task-hermes-page-ai-smoke.js` 可渲染 mocked `run.completed.agentAudit.changedFiles` 的 `agent.changed_files` 工具卡。
|
- 验证:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task453-local-folder-page-ai-changed-files-smoke.js` 覆盖本地 `README.md` 写入标记后,会话面板显示 `agent.changed_files`、`README.md` 与 diff 摘要。
|
||||||
- 待补:真实 local_folder agent run 写入 `.md` 后,由后端审计事件驱动会话面板展示 changed files。
|
|
||||||
|
|
||||||
补充验证:
|
补充验证:
|
||||||
|
|
||||||
@@ -182,14 +182,17 @@
|
|||||||
- 实现:单 workspace 本地索引落在授权 root 的 `.mnote/index/search-index.json`;control-plane index 只保留给后续跨 workspace / shared cache 总索引。
|
- 实现:单 workspace 本地索引落在授权 root 的 `.mnote/index/search-index.json`;control-plane index 只保留给后续跨 workspace / shared cache 总索引。
|
||||||
- [x] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。
|
- [x] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。
|
||||||
- 实现:`/api/search/documents` 的 `local_folder` 分支先走 `ensure_local_workspace_read_access`,再对 canonical root 内 `.md` 文件重建索引。
|
- 实现:`/api/search/documents` 的 `local_folder` 分支先走 `ensure_local_workspace_read_access`,再对 canonical root 内 `.md` 文件重建索引。
|
||||||
- [ ] 定义索引输入:markdown 正文、frontmatter、附件引用、mindmap / office resource metadata。
|
- [x] 定义索引输入:markdown 正文、frontmatter、附件引用、mindmap / office resource metadata。
|
||||||
- 已完成最小输入:markdown 正文、frontmatter `title/tags/mnote_id`、markdown 链接、双链、附件 / mindmap / office 文件引用;待补 mindmap / office 原生 metadata 索引。
|
- 实现:输入覆盖 markdown 正文、frontmatter `title/tags/mnote_id`、markdown 链接、双链、附件引用、`.mindmap.json` 与 office 文件元数据。
|
||||||
|
- 验证:`cargo test -p mnote-web local_search_index -- --nocapture`
|
||||||
- [x] 定义索引输出:全文 search、backlinks、resource refs、tags、recent changes。
|
- [x] 定义索引输出:全文 search、backlinks、resource refs、tags、recent changes。
|
||||||
- 实现:本地索引输出 `results` 与 `recentChanges`,结果包含全文命中、`backlinks`、`resourceRefs`、`tags`、`path`、`resourceType`。
|
- 实现:本地索引输出 `results` 与 `recentChanges`,结果包含全文命中、`backlinks`、`resourceRefs`、`tags`、`path`、`resourceType`,并可返回 mindmap / office 资源元数据搜索结果。
|
||||||
|
|
||||||
### 4.2 索引更新
|
### 4.2 索引更新
|
||||||
|
|
||||||
- [ ] 本地文件 watcher 事件触发增量索引。
|
- [x] 本地文件 watcher 事件触发增量索引。
|
||||||
|
- 实现:本地 markdown / mindmap / office watcher 事件会调用单文件索引更新 `refresh_local_search_index_for_path`,避免等到搜索时才重建。
|
||||||
|
- 验证:`cargo test -p mnote-web watcher_event_refreshes_local_search_index_path -- --nocapture`
|
||||||
- [x] 手动 refresh / resync 触发 root 全量索引。
|
- [x] 手动 refresh / resync 触发 root 全量索引。
|
||||||
- 实现:`POST /api/search/local-index/refresh` 会在授权 root 内全量重建 `.mnote/index/search-index.json`。
|
- 实现:`POST /api/search/local-index/refresh` 会在授权 root 内全量重建 `.mnote/index/search-index.json`。
|
||||||
- 验证:`cargo test -p mnote-web search_local_index_refresh_rebuilds_authorized_root -- --nocapture`
|
- 验证:`cargo test -p mnote-web search_local_index_refresh_rebuilds_authorized_root -- --nocapture`
|
||||||
@@ -208,10 +211,10 @@
|
|||||||
- 实现:页面搜索请求携带 `sourceKind/rootUri`;`local_folder` 请求直接查本地索引,不先走 Convex runtime。
|
- 实现:页面搜索请求携带 `sourceKind/rootUri`;`local_folder` 请求直接查本地索引,不先走 Convex runtime。
|
||||||
- [x] 搜索结果显示文件路径、标题、命中片段和资源类型。
|
- [x] 搜索结果显示文件路径、标题、命中片段和资源类型。
|
||||||
- 实现:搜索结果行显示标题、命中片段、路径和 `resourceType` 标识;API 同步返回 `path/title/snippet/resourceType`。
|
- 实现:搜索结果行显示标题、命中片段、路径和 `resourceType` 标识;API 同步返回 `path/title/snippet/resourceType`。
|
||||||
- [ ] 反链面板读取本地索引,不再依赖云端搜索。
|
- [x] 反链面板读取本地索引,不再依赖云端搜索。
|
||||||
- 已完成数据源:`GET /api/search/local-index/backlinks` 从授权 root 本地索引读取反链;待挂接真实反链面板 UI。
|
- 实现:页面设置 drawer 新增“索引”页签,按当前授权 root 读取 `GET /api/search/local-index/backlinks`。
|
||||||
- [ ] 标签列表读取本地索引。
|
- [x] 标签列表读取本地索引。
|
||||||
- 已完成数据源:`GET /api/search/local-index/tags` 从授权 root 本地索引读取标签聚合;待挂接真实标签列表 UI。
|
- 实现:页面设置 drawer 新增“索引”页签,按当前授权 root 读取 `GET /api/search/local-index/tags`。
|
||||||
|
|
||||||
建议验证:
|
建议验证:
|
||||||
|
|
||||||
@@ -219,8 +222,12 @@
|
|||||||
- 验证:`cargo test -p mnote-web local_search_index -- --nocapture`
|
- 验证:`cargo test -p mnote-web local_search_index -- --nocapture`
|
||||||
- [x] 单测:附件 / mindmap / office 引用可生成 resource refs。
|
- [x] 单测:附件 / mindmap / office 引用可生成 resource refs。
|
||||||
- 验证:`cargo test -p mnote-web local_search_index -- --nocapture`
|
- 验证:`cargo test -p mnote-web local_search_index -- --nocapture`
|
||||||
|
- [x] 单测:mindmap / office 原生资源元数据可进入本地索引结果。
|
||||||
|
- 验证:`cargo test -p mnote-web local_search_index -- --nocapture`
|
||||||
- [x] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。
|
- [x] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。
|
||||||
- 验证:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task452-local-search-index-browser-smoke.js`
|
- 验证:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task452-local-search-index-browser-smoke.js`
|
||||||
|
- [x] browser smoke:页面设置里的本地索引页签可显示反链和标签。
|
||||||
|
- 验证:`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task452-local-search-index-browser-smoke.js`
|
||||||
|
|
||||||
补充验证:
|
补充验证:
|
||||||
|
|
||||||
@@ -369,15 +376,15 @@
|
|||||||
|
|
||||||
后续每次持续推进本 checklist,至少执行与改动相关的子集;跨域改动需要执行完整包。
|
后续每次持续推进本 checklist,至少执行与改动相关的子集;跨域改动需要执行完整包。
|
||||||
|
|
||||||
- [ ] `cargo fmt --check --all`
|
- [x] `cargo fmt --check --all`
|
||||||
- [ ] `cargo test -p mnote-web local_folder -- --nocapture`
|
- [x] `cargo test -p mnote-web local_folder -- --nocapture`
|
||||||
- [ ] `cargo test -p mnote-web local_access_policy -- --nocapture`
|
- [x] `cargo test -p mnote-web local_access_policy -- --nocapture`
|
||||||
- [ ] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture`
|
- [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture`
|
||||||
- [ ] `cargo test -p mnote-web tree_command -- --nocapture`
|
- [x] `cargo test -p mnote-web tree_command -- --nocapture`
|
||||||
- [ ] `cargo test -p mnote-web page_aggregate -- --nocapture`
|
- [x] `cargo test -p mnote-web page_aggregate -- --nocapture`
|
||||||
- [ ] `npm run check:local-first-convex-guard`
|
- [x] `npm run check:local-first-convex-guard`
|
||||||
- [ ] `git diff --check -- <changed-files>`
|
- [x] `git diff --check -- <changed-files>`
|
||||||
- [ ] 影响 UI / 交互时补 browser smoke,并把脚本名写回对应阶段。
|
- [x] 影响 UI / 交互时补 browser smoke,并把脚本名写回对应阶段。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -389,8 +396,8 @@
|
|||||||
- 证据:`node scripts/task450-admin-access-policy-ui-smoke.js`;`cargo test -p mnote-web admin_access_policy -- --nocapture`。
|
- 证据:`node scripts/task450-admin-access-policy-ui-smoke.js`;`cargo test -p mnote-web admin_access_policy -- --nocapture`。
|
||||||
- [x] P1 冲突处理 UI 完成,并覆盖 tiptap 保存与 agent 写回冲突。
|
- [x] P1 冲突处理 UI 完成,并覆盖 tiptap 保存与 agent 写回冲突。
|
||||||
- 证据:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`;`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture`。
|
- 证据:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`;`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture`。
|
||||||
- [ ] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。
|
- [x] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。
|
||||||
- [ ] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。
|
- [x] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。
|
||||||
- [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。
|
- [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。
|
||||||
- [ ] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。
|
- [ ] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。
|
||||||
- [ ] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。
|
- [ ] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::routes::{local_workspace_id_from_root_uri, refresh_local_search_index_for_path};
|
||||||
use notify::event::ModifyKind;
|
use notify::event::ModifyKind;
|
||||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -191,12 +192,20 @@ fn spawn_local_folder_watcher(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
if !is_markdown_path(&path) {
|
if !is_local_search_index_path(&path) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(relative_path) = relative_path_string(&canonical_root, &path) else {
|
let Some(relative_path) = relative_path_string(&canonical_root, &path) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
refresh_local_search_index_for_event(
|
||||||
|
&canonical_root,
|
||||||
|
&root_uri_for_task,
|
||||||
|
&relative_path,
|
||||||
|
);
|
||||||
|
if !is_markdown_path(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
"rootUri": root_uri_for_task,
|
"rootUri": root_uri_for_task,
|
||||||
@@ -215,6 +224,13 @@ fn spawn_local_folder_watcher(
|
|||||||
Ok((sender, shutdown_tx))
|
Ok((sender, shutdown_tx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn refresh_local_search_index_for_event(root: &Path, root_uri: &str, relative_path: &str) {
|
||||||
|
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = refresh_local_search_index_for_path(root, root_uri, &workspace_id, relative_path);
|
||||||
|
}
|
||||||
|
|
||||||
fn canonical_root_uri(root: &Path) -> String {
|
fn canonical_root_uri(root: &Path) -> String {
|
||||||
format!("file://{}", root.display())
|
format!("file://{}", root.display())
|
||||||
}
|
}
|
||||||
@@ -235,6 +251,29 @@ fn is_markdown_path(path: &Path) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_local_search_index_path(path: &Path) -> bool {
|
||||||
|
if is_markdown_path(path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase();
|
||||||
|
if file_name.ends_with(".mindmap.json") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase();
|
||||||
|
matches!(
|
||||||
|
extension.as_str(),
|
||||||
|
"doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn should_emit_event_kind(kind: &EventKind) -> bool {
|
fn should_emit_event_kind(kind: &EventKind) -> bool {
|
||||||
match kind {
|
match kind {
|
||||||
EventKind::Create(_) | EventKind::Remove(_) => true,
|
EventKind::Create(_) | EventKind::Remove(_) => true,
|
||||||
@@ -276,7 +315,10 @@ fn system_time_ms(time: SystemTime) -> u128 {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{should_emit_event_kind, LocalFolderWatcherRegistry};
|
use super::{
|
||||||
|
is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind,
|
||||||
|
LocalFolderWatcherRegistry,
|
||||||
|
};
|
||||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||||
use notify::EventKind;
|
use notify::EventKind;
|
||||||
|
|
||||||
@@ -348,4 +390,42 @@ mod tests {
|
|||||||
AccessKind::Read
|
AccessKind::Read
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_index_path_filter_accepts_markdown_and_resources() {
|
||||||
|
assert!(is_local_search_index_path(&std::path::Path::new(
|
||||||
|
"docs/page.md"
|
||||||
|
)));
|
||||||
|
assert!(is_local_search_index_path(&std::path::Path::new(
|
||||||
|
"maps/idea.mindmap.json"
|
||||||
|
)));
|
||||||
|
assert!(is_local_search_index_path(&std::path::Path::new(
|
||||||
|
"office/report.xlsx"
|
||||||
|
)));
|
||||||
|
assert!(!is_local_search_index_path(&std::path::Path::new(
|
||||||
|
"docs/image.png"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watcher_event_refreshes_local_search_index_path() {
|
||||||
|
let root = test_root("search-index-event");
|
||||||
|
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||||
|
std::fs::write(root.join("README.md"), "# Home\n").expect("write home");
|
||||||
|
std::fs::write(
|
||||||
|
root.join("docs").join("watched.md"),
|
||||||
|
"---\ntitle: Watched\n---\n# Watched\nWatcherToken\n",
|
||||||
|
)
|
||||||
|
.expect("write watched");
|
||||||
|
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
refresh_local_search_index_for_event(&root, &root_uri, "docs/watched.md");
|
||||||
|
|
||||||
|
let index_path = root.join(".mnote").join("index").join("search-index.json");
|
||||||
|
let index = std::fs::read_to_string(&index_path).expect("index exists");
|
||||||
|
assert!(index.contains("docs/watched.md"));
|
||||||
|
assert!(index.contains("WatcherToken"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::routes::local_markdown_parser::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;
|
const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;
|
||||||
@@ -18,6 +18,8 @@ struct LocalSearchIndex {
|
|||||||
root_uri: String,
|
root_uri: String,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
documents: Vec<LocalSearchDocument>,
|
documents: Vec<LocalSearchDocument>,
|
||||||
|
#[serde(default)]
|
||||||
|
resources: Vec<LocalSearchResource>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -33,6 +35,16 @@ struct LocalSearchDocument {
|
|||||||
updated_at: u128,
|
updated_at: u128,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalSearchResource {
|
||||||
|
resource_id: String,
|
||||||
|
resource_type: String,
|
||||||
|
title: String,
|
||||||
|
path: String,
|
||||||
|
updated_at: u128,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn query_local_search_index(
|
pub(crate) fn query_local_search_index(
|
||||||
root_path: &Path,
|
root_path: &Path,
|
||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
@@ -66,6 +78,17 @@ pub(crate) fn query_local_search_index(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if page_id.is_none() && results.len() < limit.max(1) as usize {
|
||||||
|
for resource in index.resources.iter() {
|
||||||
|
if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(local_search_resource_projection(resource, root_uri));
|
||||||
|
if results.len() >= limit.max(1) as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"enqueueAssetIds": [],
|
"enqueueAssetIds": [],
|
||||||
"projectionOwner": "rust-kernel",
|
"projectionOwner": "rust-kernel",
|
||||||
@@ -75,7 +98,8 @@ pub(crate) fn query_local_search_index(
|
|||||||
"rootUri": index.root_uri,
|
"rootUri": index.root_uri,
|
||||||
"workspaceId": index.workspace_id,
|
"workspaceId": index.workspace_id,
|
||||||
"builtAt": index.built_at,
|
"builtAt": index.built_at,
|
||||||
"documentCount": index.documents.len()
|
"documentCount": index.documents.len(),
|
||||||
|
"resourceCount": index.resources.len()
|
||||||
},
|
},
|
||||||
"recentChanges": recent_changes,
|
"recentChanges": recent_changes,
|
||||||
"results": results
|
"results": results
|
||||||
@@ -93,7 +117,65 @@ pub(crate) fn refresh_local_search_index(
|
|||||||
"rootUri": index.root_uri,
|
"rootUri": index.root_uri,
|
||||||
"workspaceId": index.workspace_id,
|
"workspaceId": index.workspace_id,
|
||||||
"builtAt": index.built_at,
|
"builtAt": index.built_at,
|
||||||
"documentCount": index.documents.len()
|
"documentCount": index.documents.len(),
|
||||||
|
"resourceCount": index.resources.len()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn refresh_local_search_index_for_path(
|
||||||
|
root_path: &Path,
|
||||||
|
root_uri: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
relative_path: &str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let relative_path = normalize_index_relative_path(relative_path)?;
|
||||||
|
let mut index = match read_local_search_index(root_path) {
|
||||||
|
Ok(Some(index))
|
||||||
|
if index.version == LOCAL_SEARCH_INDEX_VERSION
|
||||||
|
&& index.root_uri == root_uri
|
||||||
|
&& index.workspace_id == workspace_id =>
|
||||||
|
{
|
||||||
|
index
|
||||||
|
}
|
||||||
|
Ok(_) | Err(_) => {
|
||||||
|
return refresh_local_search_index(root_path, root_uri, workspace_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
index
|
||||||
|
.documents
|
||||||
|
.retain(|document| document.path != relative_path);
|
||||||
|
index
|
||||||
|
.resources
|
||||||
|
.retain(|resource| resource.path != relative_path);
|
||||||
|
let absolute_path = root_path.join(&relative_path);
|
||||||
|
if absolute_path.exists() && absolute_path.is_file() && is_markdown_path(&absolute_path) {
|
||||||
|
index
|
||||||
|
.documents
|
||||||
|
.push(index_markdown_file(root_path, &absolute_path)?);
|
||||||
|
} else if absolute_path.exists()
|
||||||
|
&& absolute_path.is_file()
|
||||||
|
&& resource_type_from_path(&absolute_path).is_some()
|
||||||
|
{
|
||||||
|
index
|
||||||
|
.resources
|
||||||
|
.push(index_resource_file(root_path, &absolute_path)?);
|
||||||
|
}
|
||||||
|
index
|
||||||
|
.documents
|
||||||
|
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
|
index
|
||||||
|
.resources
|
||||||
|
.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
|
index.built_at = now_ms();
|
||||||
|
write_local_search_index(root_path, &index)?;
|
||||||
|
Ok(json!({
|
||||||
|
"version": index.version,
|
||||||
|
"rootUri": index.root_uri,
|
||||||
|
"workspaceId": index.workspace_id,
|
||||||
|
"builtAt": index.built_at,
|
||||||
|
"documentCount": index.documents.len(),
|
||||||
|
"resourceCount": index.resources.len()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,23 +277,76 @@ fn rebuild_local_search_index(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
) -> Result<LocalSearchIndex, WebError> {
|
) -> Result<LocalSearchIndex, WebError> {
|
||||||
let mut documents = Vec::new();
|
let mut documents = Vec::new();
|
||||||
collect_markdown_documents(root_path, root_path, &mut documents)?;
|
let mut resources = Vec::new();
|
||||||
|
collect_markdown_documents(root_path, root_path, &mut documents, &mut resources)?;
|
||||||
documents.sort_by(|left, right| left.path.cmp(&right.path));
|
documents.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
|
resources.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
let index = LocalSearchIndex {
|
let index = LocalSearchIndex {
|
||||||
version: LOCAL_SEARCH_INDEX_VERSION,
|
version: LOCAL_SEARCH_INDEX_VERSION,
|
||||||
built_at: now_ms(),
|
built_at: now_ms(),
|
||||||
root_uri: root_uri.to_string(),
|
root_uri: root_uri.to_string(),
|
||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
documents,
|
documents,
|
||||||
|
resources,
|
||||||
};
|
};
|
||||||
write_local_search_index(root_path, &index)?;
|
write_local_search_index(root_path, &index)?;
|
||||||
Ok(index)
|
Ok(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_local_search_index(root_path: &Path) -> Result<Option<LocalSearchIndex>, WebError> {
|
||||||
|
let index_path = root_path
|
||||||
|
.join(".mnote")
|
||||||
|
.join("index")
|
||||||
|
.join("search-index.json");
|
||||||
|
if !index_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&index_path).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_search_index_read_failed",
|
||||||
|
format!("无法读取本地搜索索引 {}: {error}", index_path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
serde_json::from_str::<LocalSearchIndex>(&content)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_search_index_invalid",
|
||||||
|
format!("本地搜索索引格式非法 {}: {error}", index_path.display()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_index_relative_path(relative_path: &str) -> Result<String, WebError> {
|
||||||
|
let normalized = relative_path.trim().replace('\\', "/");
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_search_index_path_required",
|
||||||
|
"本地搜索索引更新缺少相对路径",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(&normalized);
|
||||||
|
if path.is_absolute()
|
||||||
|
|| path.components().any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(WebError::bad_request_code(
|
||||||
|
"local_search_index_path_escape",
|
||||||
|
"本地搜索索引相对路径不能越过 root",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_markdown_documents(
|
fn collect_markdown_documents(
|
||||||
root_path: &Path,
|
root_path: &Path,
|
||||||
current: &Path,
|
current: &Path,
|
||||||
documents: &mut Vec<LocalSearchDocument>,
|
documents: &mut Vec<LocalSearchDocument>,
|
||||||
|
resources: &mut Vec<LocalSearchResource>,
|
||||||
) -> Result<(), WebError> {
|
) -> Result<(), WebError> {
|
||||||
let entries = match fs::read_dir(current) {
|
let entries = match fs::read_dir(current) {
|
||||||
Ok(entries) => entries,
|
Ok(entries) => entries,
|
||||||
@@ -244,13 +379,17 @@ fn collect_markdown_documents(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if file_type.is_dir() {
|
if file_type.is_dir() {
|
||||||
collect_markdown_documents(root_path, &path, documents)?;
|
collect_markdown_documents(root_path, &path, documents, resources)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !file_type.is_file() || !is_markdown_path(&path) {
|
if !file_type.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
documents.push(index_markdown_file(root_path, &path)?);
|
if is_markdown_path(&path) {
|
||||||
|
documents.push(index_markdown_file(root_path, &path)?);
|
||||||
|
} else if resource_type_from_path(&path).is_some() {
|
||||||
|
resources.push(index_resource_file(root_path, &path)?);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -303,6 +442,45 @@ fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocum
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn index_resource_file(root_path: &Path, path: &Path) -> Result<LocalSearchResource, WebError> {
|
||||||
|
let resource_type = resource_type_from_path(path).ok_or_else(|| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_search_index_resource_type_unsupported",
|
||||||
|
format!("不支持索引该资源文件 {}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let relative_path = path
|
||||||
|
.strip_prefix(root_path)
|
||||||
|
.unwrap_or(path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
let title = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("resource")
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(".mindmap")
|
||||||
|
.to_string();
|
||||||
|
let metadata = fs::metadata(path).map_err(|error| {
|
||||||
|
WebError::bad_request_code(
|
||||||
|
"local_search_index_stat_failed",
|
||||||
|
format!("无法读取本地资源索引文件状态 {}: {error}", path.display()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(LocalSearchResource {
|
||||||
|
resource_id: format!("local-resource:{}", relative_path.replace('/', "~2F")),
|
||||||
|
resource_type: resource_type.to_string(),
|
||||||
|
title,
|
||||||
|
path: relative_path,
|
||||||
|
updated_at: metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|value| value.as_millis())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
|
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
|
||||||
let index_dir = root_path.join(".mnote").join("index");
|
let index_dir = root_path.join(".mnote").join("index");
|
||||||
fs::create_dir_all(&index_dir).map_err(|error| {
|
fs::create_dir_all(&index_dir).map_err(|error| {
|
||||||
@@ -349,6 +527,30 @@ fn local_search_document_matches(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_search_resource_matches(
|
||||||
|
resource: &LocalSearchResource,
|
||||||
|
query: &str,
|
||||||
|
title_only: bool,
|
||||||
|
exact: bool,
|
||||||
|
) -> bool {
|
||||||
|
if query.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let haystack = if title_only {
|
||||||
|
normalize_search_text(&resource.title)
|
||||||
|
} else {
|
||||||
|
normalize_search_text(&format!(
|
||||||
|
"{}\n{}\n{}",
|
||||||
|
resource.title, resource.path, resource.resource_type
|
||||||
|
))
|
||||||
|
};
|
||||||
|
if exact {
|
||||||
|
haystack == query
|
||||||
|
} else {
|
||||||
|
haystack.contains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn local_search_document_projection(
|
fn local_search_document_projection(
|
||||||
document: &LocalSearchDocument,
|
document: &LocalSearchDocument,
|
||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
@@ -371,6 +573,20 @@ fn local_search_document_projection(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": resource.resource_id,
|
||||||
|
"documentId": resource.resource_id,
|
||||||
|
"title": resource.title,
|
||||||
|
"path": resource.path,
|
||||||
|
"resourceType": resource.resource_type,
|
||||||
|
"sourceKind": "local_folder",
|
||||||
|
"rootUri": root_uri,
|
||||||
|
"updatedAt": resource.updated_at,
|
||||||
|
"publicPath": format!("/tree?sourceKind=local_folder&rootUri={}", root_uri)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value {
|
fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value {
|
||||||
let mut sorted = documents.iter().collect::<Vec<_>>();
|
let mut sorted = documents.iter().collect::<Vec<_>>();
|
||||||
sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
|
sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
|
||||||
@@ -522,6 +738,26 @@ fn is_markdown_path(path: &Path) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resource_type_from_path(path: &Path) -> Option<&'static str> {
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase();
|
||||||
|
if file_name.ends_with(".mindmap.json") {
|
||||||
|
return Some("mindmap");
|
||||||
|
}
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase();
|
||||||
|
match extension.as_str() {
|
||||||
|
"doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" => Some("office"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_search_text(value: &str) -> String {
|
fn normalize_search_text(value: &str) -> String {
|
||||||
value.trim().to_lowercase()
|
value.trim().to_lowercase()
|
||||||
}
|
}
|
||||||
@@ -558,6 +794,14 @@ mod tests {
|
|||||||
"---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n",
|
"---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n",
|
||||||
)
|
)
|
||||||
.expect("write child");
|
.expect("write child");
|
||||||
|
fs::create_dir_all(root.join("maps")).expect("create maps");
|
||||||
|
fs::create_dir_all(root.join("office")).expect("create office");
|
||||||
|
fs::write(
|
||||||
|
root.join("maps").join("idea.mindmap.json"),
|
||||||
|
r#"{"title":"Idea Map","nodes":[]}"#,
|
||||||
|
)
|
||||||
|
.expect("write mindmap");
|
||||||
|
fs::write(root.join("office").join("report.xlsx"), b"office bytes").expect("write office");
|
||||||
|
|
||||||
let projection = query_local_search_index(
|
let projection = query_local_search_index(
|
||||||
&root,
|
&root,
|
||||||
@@ -610,6 +854,92 @@ mod tests {
|
|||||||
.join("index")
|
.join("index")
|
||||||
.join("search-index.json")
|
.join("search-index.json")
|
||||||
.exists());
|
.exists());
|
||||||
|
let mindmap_projection = query_local_search_index(
|
||||||
|
&root,
|
||||||
|
&format!("file://{}", root.display()),
|
||||||
|
"local-ws-test",
|
||||||
|
"idea.mindmap",
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("mindmap projection");
|
||||||
|
assert!(mindmap_projection["results"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||||
|
&& item["path"].as_str() == Some("maps/idea.mindmap.json")));
|
||||||
|
let office_projection = query_local_search_index(
|
||||||
|
&root,
|
||||||
|
&format!("file://{}", root.display()),
|
||||||
|
"local-ws-test",
|
||||||
|
"report.xlsx",
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("office projection");
|
||||||
|
assert!(office_projection["results"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||||
|
&& item["path"].as_str() == Some("office/report.xlsx")));
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_search_index_incrementally_updates_single_markdown_path() {
|
||||||
|
let root = temp_root("mnote-local-search-index-incremental");
|
||||||
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
let workspace_id = "local-ws-incremental";
|
||||||
|
fs::write(root.join("README.md"), "# Home\nRoot body.\n").expect("write home");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("child.md"),
|
||||||
|
"---\ntitle: Child\n---\n# Child\nOriginal body.\n",
|
||||||
|
)
|
||||||
|
.expect("write child");
|
||||||
|
|
||||||
|
refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
|
||||||
|
fs::write(
|
||||||
|
root.join("docs").join("child.md"),
|
||||||
|
"---\ntitle: Child Updated\n---\n# Child Updated\nChangedToken body.\n",
|
||||||
|
)
|
||||||
|
.expect("update child");
|
||||||
|
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
|
||||||
|
.expect("incremental update");
|
||||||
|
let index = read_local_search_index(&root)
|
||||||
|
.expect("read index")
|
||||||
|
.expect("index exists");
|
||||||
|
assert!(index
|
||||||
|
.documents
|
||||||
|
.iter()
|
||||||
|
.any(|document| document.path == "README.md"));
|
||||||
|
let child = index
|
||||||
|
.documents
|
||||||
|
.iter()
|
||||||
|
.find(|document| document.path == "docs/child.md")
|
||||||
|
.expect("child document");
|
||||||
|
assert_eq!(child.title, "Child Updated");
|
||||||
|
assert!(child.raw_text.contains("ChangedToken"));
|
||||||
|
|
||||||
|
fs::remove_file(root.join("docs").join("child.md")).expect("remove child");
|
||||||
|
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
|
||||||
|
.expect("incremental remove");
|
||||||
|
let index = read_local_search_index(&root)
|
||||||
|
.expect("read index")
|
||||||
|
.expect("index exists");
|
||||||
|
assert!(!index
|
||||||
|
.documents
|
||||||
|
.iter()
|
||||||
|
.any(|document| document.path == "docs/child.md"));
|
||||||
|
assert!(index
|
||||||
|
.documents
|
||||||
|
.iter()
|
||||||
|
.any(|document| document.path == "README.md"));
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ pub(crate) mod web_shell;
|
|||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
pub(crate) use local_folder_source::{
|
pub(crate) use local_folder_source::{
|
||||||
ensure_local_path_read_access, ensure_local_workspace_access, update_local_markdown_title,
|
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
|
||||||
update_local_page_options, write_local_markdown_page_body,
|
update_local_markdown_title, update_local_page_options, write_local_markdown_page_body,
|
||||||
};
|
};
|
||||||
|
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
||||||
|
|
||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use axum::routing::{any, delete, get, post, put};
|
use axum::routing::{any, delete, get, post, put};
|
||||||
|
|||||||
@@ -67,7 +67,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
pageAiSessionSearchResults: [],
|
pageAiSessionSearchResults: [],
|
||||||
pageAiSessionSearchTimer: 0,
|
pageAiSessionSearchTimer: 0,
|
||||||
pageAiSessionError: '',
|
pageAiSessionError: '',
|
||||||
pageAiPermissionRequests: []
|
pageAiPermissionRequests: [],
|
||||||
|
localIndexSummary: {
|
||||||
|
scopeKey: '',
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
backlinks: null,
|
||||||
|
tags: null
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function closestAction(target, selector) {
|
function closestAction(target, selector) {
|
||||||
@@ -6497,6 +6504,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
|
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
|
||||||
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page">页面选项</button>' +
|
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page">页面选项</button>' +
|
||||||
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom">自定义页面</button>' +
|
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom">自定义页面</button>' +
|
||||||
|
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="index">索引</button>' +
|
||||||
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global">全局选项</button>' +
|
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global">全局选项</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
|
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
|
||||||
@@ -6512,6 +6520,19 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
createPageOptionRow('hideChildPages', 'checkbox') +
|
createPageOptionRow('hideChildPages', 'checkbox') +
|
||||||
createPageOptionRow('showBlockRefCount', 'checkbox') +
|
createPageOptionRow('showBlockRefCount', 'checkbox') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="wolai-page-settings-section" data-page-settings-panel="index" hidden>' +
|
||||||
|
'<div class="wolai-page-settings-index-panel" data-testid="wolai-page-settings-local-index-panel">' +
|
||||||
|
'<div class="wolai-page-settings-index-status" data-testid="wolai-page-settings-local-index-status"></div>' +
|
||||||
|
'<section class="wolai-page-settings-index-group">' +
|
||||||
|
'<div class="wolai-page-settings-index-title">反链</div>' +
|
||||||
|
'<div class="wolai-page-settings-index-list" data-testid="wolai-page-settings-local-index-backlinks"></div>' +
|
||||||
|
'</section>' +
|
||||||
|
'<section class="wolai-page-settings-index-group">' +
|
||||||
|
'<div class="wolai-page-settings-index-title">标签</div>' +
|
||||||
|
'<div class="wolai-page-settings-index-list" data-testid="wolai-page-settings-local-index-tags"></div>' +
|
||||||
|
'</section>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
|
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
|
||||||
createGlobalHeadingNumbersRow() +
|
createGlobalHeadingNumbersRow() +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -6528,6 +6549,149 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return popover;
|
return popover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageSettingsLocalIndexScopeKey() {
|
||||||
|
return [
|
||||||
|
currentSourceKind(),
|
||||||
|
currentRootUri(),
|
||||||
|
resolveWorkspaceId(document.body),
|
||||||
|
currentDocumentId()
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageSettingsLocalIndexIsAvailable() {
|
||||||
|
return currentSourceKind() === 'local_folder' && Boolean(currentRootUri()) && Boolean(currentDocumentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageSettingsLocalIndexEmpty(message) {
|
||||||
|
return '<div class="wolai-page-settings-index-empty">' + escapeHtml(message) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPageSettingsLocalIndexList(items, kind) {
|
||||||
|
var rows = Array.isArray(items) ? items : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
return pageSettingsLocalIndexEmpty(kind === 'backlinks' ? '暂无反链' : '暂无标签');
|
||||||
|
}
|
||||||
|
if (kind === 'backlinks') {
|
||||||
|
return rows.slice(0, 12).map(function(item) {
|
||||||
|
var title = searchText(item && item.title) || searchText(item && item.path) || '未命名页面';
|
||||||
|
var path = searchText(item && item.path);
|
||||||
|
var snippet = searchText(item && item.snippet);
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-settings-index-row">' +
|
||||||
|
'<div class="wolai-page-settings-index-row-title">' + escapeHtml(title) + '</div>' +
|
||||||
|
(path ? '<div class="wolai-page-settings-index-row-meta">' + escapeHtml(path) + '</div>' : '') +
|
||||||
|
(snippet ? '<div class="wolai-page-settings-index-row-snippet">' + escapeHtml(snippet) + '</div>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
return rows.slice(0, 16).map(function(item) {
|
||||||
|
var tag = searchText(item && item.tag) || 'untagged';
|
||||||
|
var count = Number(item && item.count || 0);
|
||||||
|
return '' +
|
||||||
|
'<div class="wolai-page-settings-index-row is-tag">' +
|
||||||
|
'<div class="wolai-page-settings-index-row-title">#' + escapeHtml(tag) + '</div>' +
|
||||||
|
'<div class="wolai-page-settings-index-row-meta">' + count + ' 个页面</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPageSettingsLocalIndex(popover) {
|
||||||
|
popover = popover || ensurePageSettingsPopover();
|
||||||
|
var statusNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-status"]');
|
||||||
|
var backlinksNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
||||||
|
var tagsNode = popover.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
||||||
|
if (!(statusNode instanceof HTMLElement) || !(backlinksNode instanceof HTMLElement) || !(tagsNode instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
if (!pageSettingsLocalIndexIsAvailable()) {
|
||||||
|
statusNode.textContent = '本地索引仅在本地工作区页面可用';
|
||||||
|
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root');
|
||||||
|
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('未连接本地 root');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = pageUiState.localIndexSummary || {};
|
||||||
|
if (summary.loading) {
|
||||||
|
statusNode.textContent = '正在读取本地索引...';
|
||||||
|
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('加载中');
|
||||||
|
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('加载中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (summary.error) {
|
||||||
|
statusNode.textContent = '本地索引读取失败';
|
||||||
|
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error);
|
||||||
|
tagsNode.innerHTML = pageSettingsLocalIndexEmpty(summary.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (summary.scopeKey !== pageSettingsLocalIndexScopeKey()) {
|
||||||
|
statusNode.textContent = '切换到索引页签后读取本地索引';
|
||||||
|
backlinksNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取');
|
||||||
|
tagsNode.innerHTML = pageSettingsLocalIndexEmpty('等待读取');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusNode.textContent = '来自当前授权 root 的 .mnote/index/search-index.json';
|
||||||
|
backlinksNode.innerHTML = renderPageSettingsLocalIndexList(summary.backlinks, 'backlinks');
|
||||||
|
tagsNode.innerHTML = renderPageSettingsLocalIndexList(summary.tags, 'tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPageSettingsLocalIndex(force) {
|
||||||
|
if (!pageSettingsLocalIndexIsAvailable()) {
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var scopeKey = pageSettingsLocalIndexScopeKey();
|
||||||
|
var current = pageUiState.localIndexSummary || {};
|
||||||
|
if (!force && current.scopeKey === scopeKey && !current.error && !current.loading) {
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pageUiState.localIndexSummary = {
|
||||||
|
scopeKey: scopeKey,
|
||||||
|
loading: true,
|
||||||
|
error: '',
|
||||||
|
backlinks: null,
|
||||||
|
tags: null
|
||||||
|
};
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
try {
|
||||||
|
var baseParams = new URLSearchParams();
|
||||||
|
baseParams.set('workspaceId', resolveWorkspaceId(document.body));
|
||||||
|
baseParams.set('rootUri', currentRootUri());
|
||||||
|
var backlinksParams = new URLSearchParams(baseParams);
|
||||||
|
backlinksParams.set('documentId', currentDocumentId());
|
||||||
|
var backlinksUrl = '/api/search/local-index/backlinks?' + backlinksParams.toString();
|
||||||
|
var tagsUrl = '/api/search/local-index/tags?' + baseParams.toString();
|
||||||
|
var responses = await Promise.all([
|
||||||
|
fetch(backlinksUrl, { headers: { accept: 'application/json' } }),
|
||||||
|
fetch(tagsUrl, { headers: { accept: 'application/json' } })
|
||||||
|
]);
|
||||||
|
var backlinksPayload = await responses[0].json().catch(function(){ return null; });
|
||||||
|
var tagsPayload = await responses[1].json().catch(function(){ return null; });
|
||||||
|
if (!responses[0].ok || !backlinksPayload || backlinksPayload.ok !== true) {
|
||||||
|
throw new Error('backlinks_' + responses[0].status);
|
||||||
|
}
|
||||||
|
if (!responses[1].ok || !tagsPayload || tagsPayload.ok !== true) {
|
||||||
|
throw new Error('tags_' + responses[1].status);
|
||||||
|
}
|
||||||
|
pageUiState.localIndexSummary = {
|
||||||
|
scopeKey: scopeKey,
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
backlinks: backlinksPayload.result && Array.isArray(backlinksPayload.result.backlinks) ? backlinksPayload.result.backlinks : [],
|
||||||
|
tags: tagsPayload.result && Array.isArray(tagsPayload.result.tags) ? tagsPayload.result.tags : []
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
pageUiState.localIndexSummary = {
|
||||||
|
scopeKey: scopeKey,
|
||||||
|
loading: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
backlinks: [],
|
||||||
|
tags: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
renderPageSettingsLocalIndex();
|
||||||
|
}
|
||||||
|
|
||||||
function renderPageSettingsPopover() {
|
function renderPageSettingsPopover() {
|
||||||
var popover = ensurePageSettingsPopover();
|
var popover = ensurePageSettingsPopover();
|
||||||
var options = currentPageOptions();
|
var options = currentPageOptions();
|
||||||
@@ -6551,6 +6715,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
'<span>块数 ' + Number(stats.blockCount || 0) + '</span>' +
|
'<span>块数 ' + Number(stats.blockCount || 0) + '</span>' +
|
||||||
'<span>待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
|
'<span>待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
|
||||||
}
|
}
|
||||||
|
renderPageSettingsLocalIndex(popover);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActivePageSettingsTab(tabName) {
|
function setActivePageSettingsTab(tabName) {
|
||||||
@@ -6563,6 +6728,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
|
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
|
||||||
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
|
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
|
||||||
});
|
});
|
||||||
|
if (tabName === 'index') void loadPageSettingsLocalIndex(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistPageOptionsPatch(patch) {
|
async function persistPageOptionsPatch(patch) {
|
||||||
|
|||||||
@@ -2835,6 +2835,76 @@ body {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-status {
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-title {
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-row,
|
||||||
|
.wolai-page-settings-index-empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #F7F6F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-row.is-tag {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-row-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1B1C1C;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wolai-page-settings-index-row-meta,
|
||||||
|
.wolai-page-settings-index-row-snippet,
|
||||||
|
.wolai-page-settings-index-empty {
|
||||||
|
color: #8B8782;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.wolai-page-setting-row {
|
.wolai-page-setting-row {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -72,7 +72,14 @@ async function run() {
|
|||||||
const debug = { root, baseUrl: BASE_URL };
|
const debug = { root, baseUrl: BASE_URL };
|
||||||
writeWorkspaceManifest(root, "user_real");
|
writeWorkspaceManifest(root, "user_real");
|
||||||
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||||
fs.writeFileSync(path.join(root, "README.md"), "# Search Smoke\n打开搜索 smoke。\n", "utf8");
|
const token = `LOCAL-SEARCH-${Date.now()}`;
|
||||||
|
const firstRelativePath = "docs/search-target.md";
|
||||||
|
const renamedRelativePath = "docs/search-renamed.md";
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "README.md"),
|
||||||
|
`---\ntitle: Search Smoke\ntags: [alpha]\n---\n# Search Smoke\n打开搜索 smoke。\n[Target](docs/search-target.md)\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -97,12 +104,9 @@ async function run() {
|
|||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = `LOCAL-SEARCH-${Date.now()}`;
|
|
||||||
const firstRelativePath = "docs/search-target.md";
|
|
||||||
const renamedRelativePath = "docs/search-renamed.md";
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(root, firstRelativePath),
|
path.join(root, firstRelativePath),
|
||||||
`---\ntitle: Search Target\n---\n# Search Target\n${token}\n`,
|
`---\ntitle: Search Target\ntags: [alpha, beta]\n---\n# Search Target\n${token}\n`,
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,6 +119,30 @@ async function run() {
|
|||||||
`新建页面应立即可搜索: ${JSON.stringify(firstResults)}`,
|
`新建页面应立即可搜索: ${JSON.stringify(firstResults)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await page.goto(documentUrl(root, firstRelativePath), {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.locator('[data-testid="wolai-page-settings-trigger"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-settings-tab="index"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const backlinks = document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]');
|
||||||
|
const tags = document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]');
|
||||||
|
return Boolean(
|
||||||
|
backlinks && backlinks.textContent && backlinks.textContent.includes("Search Smoke")
|
||||||
|
&& tags && tags.textContent && tags.textContent.includes("#alpha"),
|
||||||
|
);
|
||||||
|
}, null, { timeout: UI_TIMEOUT_MS });
|
||||||
|
debug.localIndexPanel = await page.evaluate(() => ({
|
||||||
|
backlinks: document.querySelector('[data-testid="wolai-page-settings-local-index-backlinks"]')?.textContent || "",
|
||||||
|
tags: document.querySelector('[data-testid="wolai-page-settings-local-index-tags"]')?.textContent || "",
|
||||||
|
status: document.querySelector('[data-testid="wolai-page-settings-local-index-status"]')?.textContent || "",
|
||||||
|
}));
|
||||||
|
|
||||||
fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath));
|
fs.renameSync(path.join(root, firstRelativePath), path.join(root, renamedRelativePath));
|
||||||
const second = await browserSearch(page, root, token);
|
const second = await browserSearch(page, root, token);
|
||||||
assert.equal(second.status, 200, `重命名后搜索应成功: ${JSON.stringify(second)}`);
|
assert.equal(second.status, 200, `重命名后搜索应成功: ${JSON.stringify(second)}`);
|
||||||
@@ -135,6 +163,7 @@ async function run() {
|
|||||||
token,
|
token,
|
||||||
firstPath: firstRelativePath,
|
firstPath: firstRelativePath,
|
||||||
renamedPath: renamedRelativePath,
|
renamedPath: renamedRelativePath,
|
||||||
|
localIndexPanel: debug.localIndexPanel,
|
||||||
indexExists: fs.existsSync(path.join(root, ".mnote", "index", "search-index.json")),
|
indexExists: fs.existsSync(path.join(root, ".mnote", "index", "search-index.json")),
|
||||||
};
|
};
|
||||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
BASE_URL,
|
||||||
|
UI_TIMEOUT_MS,
|
||||||
|
} = require("./tree-shell-smoke-helpers");
|
||||||
|
|
||||||
|
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||||
|
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
function fileUrl(localPath) {
|
||||||
|
return `file://${localPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeWorkspaceManifest(root, ownerId, workspaceId) {
|
||||||
|
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, ".mnote", "workspace.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
workspaceId,
|
||||||
|
ownerId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
capabilities: ["local_files", "ai_sessions", "markdown_edit"],
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-ai-changed-files-"));
|
||||||
|
const documentId = "local-md:README.md";
|
||||||
|
const actorId = "user_real";
|
||||||
|
const sessionId = `mnote_local_ai_changed_${suffix}`;
|
||||||
|
const runId = `run_local_ai_changed_${suffix}`;
|
||||||
|
const marker = `LOCAL-AI-CHANGED-FILES-${suffix}`;
|
||||||
|
const readmePath = path.join(root, "README.md");
|
||||||
|
const captured = [];
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 960 },
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
"x-mnote-actor-id": actorId,
|
||||||
|
"x-mnote-actor-type": "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspaceId = `local-ws:${actorId}:task453`;
|
||||||
|
writeWorkspaceManifest(root, actorId, workspaceId);
|
||||||
|
fs.writeFileSync(readmePath, `# Local AI Changed Files\n初始内容 ${suffix}\n`, "utf8");
|
||||||
|
const rootUri = fileUrl(root);
|
||||||
|
|
||||||
|
await page.route("**/api/ai-agent/run", async (route) => {
|
||||||
|
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/gateway/health**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
gateway: { ok: true, status: "mocked" },
|
||||||
|
profile: { name: "reasonix", modelConfigured: true, apiKeyConfigured: true },
|
||||||
|
suggestions: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/tools**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ ok: true, tools: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/profiles", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
active: "reasonix",
|
||||||
|
profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/sessions", async (route) => {
|
||||||
|
captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
title: "本地 changed files",
|
||||||
|
traceId: `trace_local_changed_${suffix}`,
|
||||||
|
persistence: "local_ai_session_jsonl",
|
||||||
|
sessionStorage: "local_private",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => {
|
||||||
|
captured.push({ kind: "session-resume", method: route.request().method(), body: "" });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
session: { sessionId, messages: [] },
|
||||||
|
runtime: {
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
status: "completed",
|
||||||
|
profile: "reasonix",
|
||||||
|
documentId,
|
||||||
|
traceId: `trace_local_changed_resume_${suffix}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/hermes/client/runs", async (route) => {
|
||||||
|
captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
events: [],
|
||||||
|
traceId: `trace_local_changed_run_${suffix}`,
|
||||||
|
persistence: "local_ai_session_jsonl",
|
||||||
|
sessionStorage: "local_private",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
|
||||||
|
captured.push({ kind: "events", method: route.request().method(), body: "" });
|
||||||
|
fs.appendFileSync(readmePath, `\nAI 写入标记:${marker}\n`, "utf8");
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
||||||
|
body:
|
||||||
|
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "已修改本地 README。" })}\n\n` +
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
event: "run.completed",
|
||||||
|
run_id: runId,
|
||||||
|
session_id: sessionId,
|
||||||
|
output: "已修改本地 README。",
|
||||||
|
agentAudit: {
|
||||||
|
eventId: `audit_local_changed_${suffix}`,
|
||||||
|
rootUri,
|
||||||
|
diffSummary: "1 changed file(s)",
|
||||||
|
changedFiles: [
|
||||||
|
{
|
||||||
|
path: "README.md",
|
||||||
|
changeType: "modified",
|
||||||
|
summary: `追加 ${marker}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
|
||||||
|
documentUrl.searchParams.set("sourceKind", "local_folder");
|
||||||
|
documentUrl.searchParams.set("rootUri", rootUri);
|
||||||
|
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.body.textContent || "").includes("Local AI Changed Files"),
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||||
|
state: "attached",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator("[data-page-ai-input]").fill(`请修改 README 并记录 changed files ${marker}`, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
|
||||||
|
return drawerText.includes("agent.changed_files") && drawerText.includes("README.md");
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = await page.$$eval("[data-page-ai-tool-card]", (nodes) =>
|
||||||
|
nodes.map((node) => ({
|
||||||
|
status: node.getAttribute("data-page-ai-tool-status"),
|
||||||
|
text: node.textContent || "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
cards.some((card) =>
|
||||||
|
card.status === "completed"
|
||||||
|
&& card.text.includes("agent.changed_files")
|
||||||
|
&& card.text.includes("README.md")
|
||||||
|
&& card.text.includes(marker)
|
||||||
|
),
|
||||||
|
`本地 AI changed files 工具卡未显示 README.md 与 diff 摘要: ${JSON.stringify(cards)}`,
|
||||||
|
);
|
||||||
|
assert(fs.readFileSync(readmePath, "utf8").includes(marker), "本地 README.md 未写入 smoke 标记");
|
||||||
|
assert(captured.some((entry) => entry.kind === "run"), "未捕获 page AI run 请求");
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
root,
|
||||||
|
documentId,
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
marker,
|
||||||
|
capturedKinds: captured.map((entry) => entry.kind),
|
||||||
|
}, null, 2),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user