From fc4a47e597c3c7eb9b7b09ff2ac700ba39fe8600 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 19 May 2026 10:22:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E8=BF=9B=E6=9C=AC=E5=9C=B0=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E4=B8=8E=20AI=20changed-files=20=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为本地工作区补充 search/backlinks/tags/resource 引用索引与 watcher 单文件刷新 - 在页面设置中增加索引页签,并展示本地反链、标签和 AI changed-files 审计 - 补充本地搜索与本地 AI changed-files 浏览器 smoke,并回填当前优先级 checklist --- ...current-priority-execution-checklist-v1.md | 57 +-- .../src/local_folder_watcher_registry.rs | 84 ++++- .../src/routes/local_search_index.rs | 344 +++++++++++++++++- rust/crates/mnote-web/src/routes/mod.rs | 5 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 168 ++++++++- rust/crates/mnote-web/src/ssr/styles.rs | 70 ++++ ...ask452-local-search-index-browser-smoke.js | 39 +- ...ocal-folder-page-ai-changed-files-smoke.js | 243 +++++++++++++ 8 files changed, 968 insertions(+), 42 deletions(-) create mode 100644 scripts/task453-local-folder-page-ai-changed-files-smoke.js diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md index beafd41b..8a0c64d1 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -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 local_folder -- --nocapture` - [x] `npm run check:local-first-convex-guard` @@ -146,8 +146,9 @@ - [x] run 结束后对比 root snapshot,生成 changed files。 - [x] 对 markdown 文件生成简短 diff summary;大文件只记录 hash / size / path。 - [x] 把审计事件落盘到 control-plane,不写进用户正文目录。 -- [ ] 在 AI 会话 UI 显示 changed files 列表,并可展开查看 diff 摘要。 - - 已接入:`run.completed.agentAudit.changedFiles` 会进入 `agent.changed_files` 工具卡并可展开查看;待真实浏览器 AI run smoke 验证后勾选。 +- [x] 在 AI 会话 UI 显示 changed files 列表,并可展开查看 diff 摘要。 + - 实现:`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` - [x] 单测:MNote tool 本地写入归入同一 run audit,并标记 `origin=mnote_tool`。 - 验证:`cargo test -p mnote-web hermes_tools_page_save_local_folder_writes_markdown_file -- --nocapture` -- [ ] 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` 工具卡。 - - 待补:真实 local_folder agent run 写入 `.md` 后,由后端审计事件驱动会话面板展示 changed files。 +- [x] browser smoke:AI 修改一篇本地 markdown 后,会话面板显示 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 摘要。 补充验证: @@ -182,14 +182,17 @@ - 实现:单 workspace 本地索引落在授权 root 的 `.mnote/index/search-index.json`;control-plane index 只保留给后续跨 workspace / shared cache 总索引。 - [x] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。 - 实现:`/api/search/documents` 的 `local_folder` 分支先走 `ensure_local_workspace_read_access`,再对 canonical root 内 `.md` 文件重建索引。 -- [ ] 定义索引输入:markdown 正文、frontmatter、附件引用、mindmap / office resource metadata。 - - 已完成最小输入:markdown 正文、frontmatter `title/tags/mnote_id`、markdown 链接、双链、附件 / mindmap / office 文件引用;待补 mindmap / office 原生 metadata 索引。 +- [x] 定义索引输入:markdown 正文、frontmatter、附件引用、mindmap / office resource 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。 - - 实现:本地索引输出 `results` 与 `recentChanges`,结果包含全文命中、`backlinks`、`resourceRefs`、`tags`、`path`、`resourceType`。 + - 实现:本地索引输出 `results` 与 `recentChanges`,结果包含全文命中、`backlinks`、`resourceRefs`、`tags`、`path`、`resourceType`,并可返回 mindmap / office 资源元数据搜索结果。 ### 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 全量索引。 - 实现:`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` @@ -208,10 +211,10 @@ - 实现:页面搜索请求携带 `sourceKind/rootUri`;`local_folder` 请求直接查本地索引,不先走 Convex runtime。 - [x] 搜索结果显示文件路径、标题、命中片段和资源类型。 - 实现:搜索结果行显示标题、命中片段、路径和 `resourceType` 标识;API 同步返回 `path/title/snippet/resourceType`。 -- [ ] 反链面板读取本地索引,不再依赖云端搜索。 - - 已完成数据源:`GET /api/search/local-index/backlinks` 从授权 root 本地索引读取反链;待挂接真实反链面板 UI。 -- [ ] 标签列表读取本地索引。 - - 已完成数据源:`GET /api/search/local-index/tags` 从授权 root 本地索引读取标签聚合;待挂接真实标签列表 UI。 +- [x] 反链面板读取本地索引,不再依赖云端搜索。 + - 实现:页面设置 drawer 新增“索引”页签,按当前授权 root 读取 `GET /api/search/local-index/backlinks`。 +- [x] 标签列表读取本地索引。 + - 实现:页面设置 drawer 新增“索引”页签,按当前授权 root 读取 `GET /api/search/local-index/tags`。 建议验证: @@ -219,8 +222,12 @@ - 验证:`cargo test -p mnote-web local_search_index -- --nocapture` - [x] 单测:附件 / mindmap / office 引用可生成 resource refs。 - 验证:`cargo test -p mnote-web local_search_index -- --nocapture` +- [x] 单测:mindmap / office 原生资源元数据可进入本地索引结果。 + - 验证:`cargo test -p mnote-web local_search_index -- --nocapture` - [x] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。 - 验证:`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,至少执行与改动相关的子集;跨域改动需要执行完整包。 -- [ ] `cargo fmt --check --all` -- [ ] `cargo test -p mnote-web local_folder -- --nocapture` -- [ ] `cargo test -p mnote-web local_access_policy -- --nocapture` -- [ ] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture` -- [ ] `cargo test -p mnote-web tree_command -- --nocapture` -- [ ] `cargo test -p mnote-web page_aggregate -- --nocapture` -- [ ] `npm run check:local-first-convex-guard` -- [ ] `git diff --check -- ` -- [ ] 影响 UI / 交互时补 browser smoke,并把脚本名写回对应阶段。 +- [x] `cargo fmt --check --all` +- [x] `cargo test -p mnote-web local_folder -- --nocapture` +- [x] `cargo test -p mnote-web local_access_policy -- --nocapture` +- [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture` +- [x] `cargo test -p mnote-web tree_command -- --nocapture` +- [x] `cargo test -p mnote-web page_aggregate -- --nocapture` +- [x] `npm run check:local-first-convex-guard` +- [x] `git diff --check -- ` +- [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`。 - [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`。 -- [ ] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。 -- [ ] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。 +- [x] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。 +- [x] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。 - [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。 - [ ] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。 - [ ] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。 diff --git a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs index 5c5f9d4b..81ec2b2b 100644 --- a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs +++ b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs @@ -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::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; @@ -191,12 +192,20 @@ fn spawn_local_folder_watcher( continue; } for path in event.paths { - if !is_markdown_path(&path) { + if !is_local_search_index_path(&path) { continue; } let Some(relative_path) = relative_path_string(&canonical_root, &path) else { continue; }; + refresh_local_search_index_for_event( + &canonical_root, + &root_uri_for_task, + &relative_path, + ); + if !is_markdown_path(&path) { + continue; + } let payload = json!({ "sourceKind": "local_folder", "rootUri": root_uri_for_task, @@ -215,6 +224,13 @@ fn spawn_local_folder_watcher( 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 { format!("file://{}", root.display()) } @@ -235,6 +251,29 @@ fn is_markdown_path(path: &Path) -> bool { .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 { match kind { EventKind::Create(_) | EventKind::Remove(_) => true, @@ -276,7 +315,10 @@ fn system_time_ms(time: SystemTime) -> u128 { #[cfg(test)] 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::EventKind; @@ -348,4 +390,42 @@ mod tests { 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); + } } diff --git a/rust/crates/mnote-web/src/routes/local_search_index.rs b/rust/crates/mnote-web/src/routes/local_search_index.rs index a97894a3..bf5873c5 100644 --- a/rust/crates/mnote-web/src/routes/local_search_index.rs +++ b/rust/crates/mnote-web/src/routes/local_search_index.rs @@ -5,7 +5,7 @@ use crate::routes::local_markdown_parser::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; const LOCAL_SEARCH_INDEX_VERSION: u32 = 1; @@ -18,6 +18,8 @@ struct LocalSearchIndex { root_uri: String, workspace_id: String, documents: Vec, + #[serde(default)] + resources: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -33,6 +35,16 @@ struct LocalSearchDocument { 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( root_path: &Path, root_uri: &str, @@ -66,6 +78,17 @@ pub(crate) fn query_local_search_index( 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!({ "enqueueAssetIds": [], "projectionOwner": "rust-kernel", @@ -75,7 +98,8 @@ pub(crate) fn query_local_search_index( "rootUri": index.root_uri, "workspaceId": index.workspace_id, "builtAt": index.built_at, - "documentCount": index.documents.len() + "documentCount": index.documents.len(), + "resourceCount": index.resources.len() }, "recentChanges": recent_changes, "results": results @@ -93,7 +117,65 @@ pub(crate) fn refresh_local_search_index( "rootUri": index.root_uri, "workspaceId": index.workspace_id, "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 { + 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, ) -> Result { 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)); + resources.sort_by(|left, right| left.path.cmp(&right.path)); let index = LocalSearchIndex { version: LOCAL_SEARCH_INDEX_VERSION, built_at: now_ms(), root_uri: root_uri.to_string(), workspace_id: workspace_id.to_string(), documents, + resources, }; write_local_search_index(root_path, &index)?; Ok(index) } +fn read_local_search_index(root_path: &Path) -> Result, 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::(&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 { + 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( root_path: &Path, current: &Path, documents: &mut Vec, + resources: &mut Vec, ) -> Result<(), WebError> { let entries = match fs::read_dir(current) { Ok(entries) => entries, @@ -244,13 +379,17 @@ fn collect_markdown_documents( ) })?; if file_type.is_dir() { - collect_markdown_documents(root_path, &path, documents)?; + collect_markdown_documents(root_path, &path, documents, resources)?; continue; } - if !file_type.is_file() || !is_markdown_path(&path) { + if !file_type.is_file() { 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(()) } @@ -303,6 +442,45 @@ fn index_markdown_file(root_path: &Path, path: &Path) -> Result Result { + 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> { let index_dir = root_path.join(".mnote").join("index"); 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( document: &LocalSearchDocument, 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 { let mut sorted = documents.iter().collect::>(); 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) } +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 { 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", ) .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( &root, @@ -610,6 +854,92 @@ mod tests { .join("index") .join("search-index.json") .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); } } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 7e076e5c..d33b5e49 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -31,9 +31,10 @@ pub(crate) mod web_shell; mod ws; pub(crate) use local_folder_source::{ - ensure_local_path_read_access, ensure_local_workspace_access, update_local_markdown_title, - update_local_page_options, write_local_markdown_page_body, + ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri, + 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 axum::routing::{any, delete, get, post, put}; diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index cdc426e9..44221044 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -67,7 +67,14 @@ const SIDEBAR_TREE_JS: &str = r##" pageAiSessionSearchResults: [], pageAiSessionSearchTimer: 0, pageAiSessionError: '', - pageAiPermissionRequests: [] + pageAiPermissionRequests: [], + localIndexSummary: { + scopeKey: '', + loading: false, + error: '', + backlinks: null, + tags: null + } }; function closestAction(target, selector) { @@ -6497,6 +6504,7 @@ const SIDEBAR_TREE_JS: &str = r##" '
' + '' + '' + + '' + '' + '
' + '
' + @@ -6512,6 +6520,19 @@ const SIDEBAR_TREE_JS: &str = r##" createPageOptionRow('hideChildPages', 'checkbox') + createPageOptionRow('showBlockRefCount', 'checkbox') + '
' + + '' + '' + @@ -6528,6 +6549,149 @@ const SIDEBAR_TREE_JS: &str = r##" 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 '
' + escapeHtml(message) + '
'; + } + + 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 '' + + '
' + + '
' + escapeHtml(title) + '
' + + (path ? '
' + escapeHtml(path) + '
' : '') + + (snippet ? '
' + escapeHtml(snippet) + '
' : '') + + '
'; + }).join(''); + } + return rows.slice(0, 16).map(function(item) { + var tag = searchText(item && item.tag) || 'untagged'; + var count = Number(item && item.count || 0); + return '' + + '
' + + '
#' + escapeHtml(tag) + '
' + + '
' + count + ' 个页面
' + + '
'; + }).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() { var popover = ensurePageSettingsPopover(); var options = currentPageOptions(); @@ -6551,6 +6715,7 @@ const SIDEBAR_TREE_JS: &str = r##" '块数 ' + Number(stats.blockCount || 0) + '' + '待办 ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + ''; } + renderPageSettingsLocalIndex(popover); } function setActivePageSettingsTab(tabName) { @@ -6563,6 +6728,7 @@ const SIDEBAR_TREE_JS: &str = r##" popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) { panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName; }); + if (tabName === 'index') void loadPageSettingsLocalIndex(false); } async function persistPageOptionsPatch(patch) { diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 7f242e21..38d7e080 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -2835,6 +2835,76 @@ body { 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 { min-height: 48px; display: flex; diff --git a/scripts/task452-local-search-index-browser-smoke.js b/scripts/task452-local-search-index-browser-smoke.js index bd9a4f9b..783618c2 100644 --- a/scripts/task452-local-search-index-browser-smoke.js +++ b/scripts/task452-local-search-index-browser-smoke.js @@ -72,7 +72,14 @@ async function run() { const debug = { root, baseUrl: BASE_URL }; writeWorkspaceManifest(root, "user_real"); 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({ headless: true, @@ -97,12 +104,9 @@ async function run() { timeout: UI_TIMEOUT_MS, }); - const token = `LOCAL-SEARCH-${Date.now()}`; - const firstRelativePath = "docs/search-target.md"; - const renamedRelativePath = "docs/search-renamed.md"; fs.writeFileSync( 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", ); @@ -115,6 +119,30 @@ async function run() { `新建页面应立即可搜索: ${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)); const second = await browserSearch(page, root, token); assert.equal(second.status, 200, `重命名后搜索应成功: ${JSON.stringify(second)}`); @@ -135,6 +163,7 @@ async function run() { token, firstPath: firstRelativePath, renamedPath: renamedRelativePath, + localIndexPanel: debug.localIndexPanel, indexExists: fs.existsSync(path.join(root, ".mnote", "index", "search-index.json")), }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); diff --git a/scripts/task453-local-folder-page-ai-changed-files-smoke.js b/scripts/task453-local-folder-page-ai-changed-files-smoke.js new file mode 100644 index 00000000..416e17b9 --- /dev/null +++ b/scripts/task453-local-folder-page-ai-changed-files-smoke.js @@ -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); +});