From f1413174422291ce7d8e8aa48b815400d73e7394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Tue, 21 Jul 2026 21:19:58 +0800 Subject: [PATCH] fix: make vault tool calls and AI links reliable --- src-tauri/Cargo.lock | 60 +++++ src-tauri/Cargo.toml | 1 + src-tauri/src/ai_model_tools.rs | 321 ++++++++++++++++++++---- src/components/MarkdownContent.test.tsx | 15 ++ src/components/MarkdownContent.tsx | 30 +++ src/hooks/useNoteActions.hook.test.ts | 11 + src/hooks/useNoteActions.ts | 6 +- 7 files changed, 392 insertions(+), 52 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7b9eb74..3044457 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1874,6 +1874,7 @@ dependencies = [ "futures-util", "gray_matter", "ironcalc_base", + "json5", "log", "notify", "objc2", @@ -2401,6 +2402,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -3262,6 +3274,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "phf" version = "0.8.0" @@ -5815,6 +5869,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5921a92..ea1afef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2.5.4", features = [] } [dependencies] serde_json = "1.0" +json5 = "0.4" serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" log = "0.4" diff --git a/src-tauri/src/ai_model_tools.rs b/src-tauri/src/ai_model_tools.rs index 7b23837..18cbde7 100644 --- a/src-tauri/src/ai_model_tools.rs +++ b/src-tauri/src/ai_model_tools.rs @@ -11,7 +11,8 @@ const DELETE_NOTE_TOOL_NAME: &str = "delete_note"; const READ_GUANGHU_URL_TOOL_NAME: &str = "read_guanghu_url"; const MAGIC_BRUSH_TOOL_NAME: &str = "magic_brush"; const FIFTH_DOMAIN_WAKE_TOOL_NAME: &str = "get_fifth_domain_wake_route"; -const FIFTH_DOMAIN_WAKE_SKILL: &str = include_str!("../resources/skills/enter-fifth-domain/SKILL.md"); +const FIFTH_DOMAIN_WAKE_SKILL: &str = + include_str!("../resources/skills/enter-fifth-domain/SKILL.md"); const MAGIC_BRUSH_TOOL_JSON: &str = r#"{ "type": "function", "function": { @@ -68,7 +69,10 @@ pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json: } fn selected_model_supports_streaming(request: &AiModelStreamRequest) -> bool { - request.provider.models.iter() + request + .provider + .models + .iter() .find(|model| model.id == request.model_id) .is_some_and(|model| model.capabilities.streaming) } @@ -121,18 +125,57 @@ fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool { } fn message_needs_magic_brush(message: &str) -> bool { - let latest = message.rsplit("[user]:").next().unwrap_or(message).to_lowercase(); + let latest = message + .rsplit("[user]:") + .next() + .unwrap_or(message) + .to_lowercase(); let trimmed = latest.trim(); let identity_only = trimmed.starts_with("我是") && trimmed.chars().count() <= 40 - && !["查", "找", "读", "写", "改", "删", "创建", "新建", "打开", "进入"].iter().any(|word| trimmed.contains(word)); + && ![ + "查", "找", "读", "写", "改", "删", "创建", "新建", "打开", "进入", + ] + .iter() + .any(|word| trimmed.contains(word)); if identity_only { return false; } [ - "search", "find", "read", "open", "create", "write", "edit", "update", "delete", "note", "file", "vault", "https://guanghulab.com/", - "搜索", "查找", "读取", "打开", "创建", "新建", "写入", "编辑", "修改", "更新", "删除", "整理", "保存", "笔记", "页面", "文件", "知识库", "路径", - ].iter().any(|word| trimmed.contains(word)) + "search", + "find", + "read", + "open", + "create", + "write", + "edit", + "update", + "delete", + "note", + "file", + "vault", + "https://guanghulab.com/", + "搜索", + "查找", + "读取", + "打开", + "创建", + "新建", + "写入", + "编辑", + "修改", + "更新", + "删除", + "整理", + "保存", + "笔记", + "页面", + "文件", + "知识库", + "路径", + ] + .iter() + .any(|word| trimmed.contains(word)) } fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool { @@ -146,9 +189,9 @@ fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool { fn openai_vault_tools() -> Vec { [MAGIC_BRUSH_TOOL_JSON] - .into_iter() - .map(|schema| serde_json::from_str(schema).expect("vault tool schema must be valid JSON")) - .collect() + .into_iter() + .map(|schema| serde_json::from_str(schema).expect("vault tool schema must be valid JSON")) + .collect() } fn run_openai_tool_calls( @@ -199,7 +242,10 @@ where return run_magic_brush_from_args(request, &tool_call.arguments, emit); } if tool_call.name != CREATE_NOTE_TOOL_NAME { - return Ok(format!("当前聊天模式不支持工具:{}。未访问或修改任何文件。", tool_call.name)); + return Ok(format!( + "当前聊天模式不支持工具:{}。未访问或修改任何文件。", + tool_call.name + )); } emit(AiAgentStreamEvent::ToolStart { @@ -236,33 +282,61 @@ fn search_notes_from_tool_args( args: &serde_json::Value, ) -> Result { let query = required_tool_string(args, "query")?; - let limit = args.get("limit").and_then(|value| value.as_u64()).unwrap_or(8).clamp(1, 20) as usize; + let limit = args + .get("limit") + .and_then(|value| value.as_u64()) + .unwrap_or(8) + .clamp(1, 20) as usize; let response = crate::search::search_vault(active_vault_path(request)?, query, "text", limit)?; if response.results.is_empty() { return Ok(format!("未找到与“{query}”匹配的笔记。")); } - let results = response.results.into_iter().map(|result| { - format!("- [[{}]]\n 路径:{}\n 摘要:{}", result.title, result.path, result.snippet) - }).collect::>().join("\n"); + let results = response + .results + .into_iter() + .map(|result| { + format!( + "- [[{}]]\n 路径:{}\n 摘要:{}", + result.title, result.path, result.snippet + ) + }) + .collect::>() + .join("\n"); Ok(format!("搜索“{query}”得到:\n{results}")) } fn vault_context_from_tool_args(request: &AiModelStreamRequest) -> Result { let vault_path = Path::new(active_vault_path(request)?); - let guidance = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "QUICKSTART-FOR-GENERAL-AI.md", ".code-map"] - .into_iter() - .filter(|name| vault_path.join(name).is_file()) - .collect::>(); + let guidance = [ + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + "QUICKSTART-FOR-GENERAL-AI.md", + ".code-map", + ] + .into_iter() + .filter(|name| vault_path.join(name).is_file()) + .collect::>(); let note_count = walkdir::WalkDir::new(vault_path) .into_iter() .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_file() && entry.path().extension().is_some_and(|ext| ext == "md" || ext == "hdlp")) + .filter(|entry| { + entry.file_type().is_file() + && entry + .path() + .extension() + .is_some_and(|ext| ext == "md" || ext == "hdlp") + }) .count(); Ok(format!( "当前知识库:{}\n可见 Markdown/HDLP 文件:{}\n入口文件:{}", vault_path.display(), note_count, - if guidance.is_empty() { "无".to_string() } else { guidance.join(", ") }, + if guidance.is_empty() { + "无".to_string() + } else { + guidance.join(", ") + }, )) } @@ -277,7 +351,13 @@ fn get_note_from_tool_args( if content.chars().count() <= MAX_TOOL_NOTE_CHARS { return Ok(content); } - Ok(format!("{}\n\n[内容过长,已截断]", content.chars().take(MAX_TOOL_NOTE_CHARS).collect::())) + Ok(format!( + "{}\n\n[内容过长,已截断]", + content + .chars() + .take(MAX_TOOL_NOTE_CHARS) + .collect::() + )) } fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result { @@ -291,16 +371,23 @@ fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result(); Ok(if body.chars().count() > MAX_PUBLIC_CHARS { format!("{clipped}\n\n[页面内容过长,已截断]") - } else { clipped }) + } else { + clipped + }) } fn resolve_existing_vault_note( @@ -311,13 +398,20 @@ fn resolve_existing_vault_note( .canonicalize() .map_err(|error| format!("Failed to resolve active vault: {error}"))?; let requested = PathBuf::from(raw_path); - let candidate = if requested.is_absolute() { requested } else { vault.join(requested) }; - let resolved = candidate.canonicalize() + let candidate = if requested.is_absolute() { + requested + } else { + vault.join(requested) + }; + let resolved = candidate + .canonicalize() .map_err(|error| format!("Failed to resolve note path: {error}"))?; if !resolved.starts_with(&vault) { return Err("Note path must stay inside the active vault.".into()); } - let supported = resolved.extension().and_then(|ext| ext.to_str()) + let supported = resolved + .extension() + .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("hdlp")); if !supported { return Err("Only Markdown and HDLP notes can be edited or deleted.".into()); @@ -355,27 +449,62 @@ where F: FnMut(AiAgentStreamEvent), { let purpose = required_tool_string(args, "purpose")?; - let steps = args.get("steps").and_then(|value| value.as_array()).ok_or_else(|| "magic_brush requires steps.".to_string())?; + let steps = args + .get("steps") + .and_then(|value| value.as_array()) + .ok_or_else(|| "magic_brush requires steps.".to_string())?; if steps.is_empty() || steps.len() > 12 { return Err("A temporary tool must contain between 1 and 12 steps.".into()); } let mut results = Vec::new(); for (index, step) in steps.iter().enumerate() { - let tool = step.get("tool").and_then(|value| value.as_str()).unwrap_or_default(); - if ![FIFTH_DOMAIN_WAKE_TOOL_NAME, SEARCH_NOTES_TOOL_NAME, GET_VAULT_CONTEXT_TOOL_NAME, GET_NOTE_TOOL_NAME, READ_GUANGHU_URL_TOOL_NAME, CREATE_NOTE_TOOL_NAME, EDIT_NOTE_TOOL_NAME, DELETE_NOTE_TOOL_NAME].contains(&tool) { + let tool = step + .get("tool") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if ![ + FIFTH_DOMAIN_WAKE_TOOL_NAME, + SEARCH_NOTES_TOOL_NAME, + GET_VAULT_CONTEXT_TOOL_NAME, + GET_NOTE_TOOL_NAME, + READ_GUANGHU_URL_TOOL_NAME, + CREATE_NOTE_TOOL_NAME, + EDIT_NOTE_TOOL_NAME, + DELETE_NOTE_TOOL_NAME, + ] + .contains(&tool) + { return Err(format!("神笔马良不能越权调用:{tool}")); } - let arguments = step.get("arguments").cloned().unwrap_or_else(|| serde_json::json!({})); + let arguments = step + .get("arguments") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); if !arguments.is_object() { return Err("Every magic_brush step requires an arguments object.".into()); } - let call = OpenAiToolCall { id: format!("magic_brush_{index}"), name: tool.to_string(), raw_arguments: arguments.to_string(), arguments }; - emit(AiAgentStreamEvent::ToolStart { tool_name: tool.to_string(), tool_id: call.id.clone(), input: Some(call.raw_arguments.clone()) }); + let call = OpenAiToolCall { + id: format!("magic_brush_{index}"), + name: tool.to_string(), + raw_arguments: arguments.to_string(), + arguments, + }; + emit(AiAgentStreamEvent::ToolStart { + tool_name: tool.to_string(), + tool_id: call.id.clone(), + input: Some(call.raw_arguments.clone()), + }); let result = execute_openai_tool_call(request, &call, emit)?; - emit(AiAgentStreamEvent::ToolDone { tool_id: call.id.clone(), output: Some("完成,继续当前对话。".into()) }); + emit(AiAgentStreamEvent::ToolDone { + tool_id: call.id.clone(), + output: Some("完成,继续当前对话。".into()), + }); results.push(result); } - Ok(format!("神笔马良已完成“{purpose}”:\n{}", results.join("\n"))) + Ok(format!( + "神笔马良已完成“{purpose}”:\n{}", + results.join("\n") + )) } fn openai_tool_calls(json: &serde_json::Value) -> Result, String> { @@ -411,8 +540,19 @@ fn openai_tool_call(index: usize, call: &serde_json::Value) -> Result Result<(serde_json::Value, String), String> { if let Some(raw) = value.as_str() { - let parsed = serde_json::from_str(raw) - .map_err(|error| format!("Failed to parse AI tool arguments: {error}"))?; + let trimmed = raw.trim(); + let unfenced = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```JSON")) + .or_else(|| trimmed.strip_prefix("```")) + .and_then(|body| body.strip_suffix("```")) + .map(str::trim) + .unwrap_or(trimmed); + let parsed = serde_json::from_str(unfenced).or_else(|strict_error| { + json5::from_str(unfenced).map_err(|lenient_error| { + format!("Failed to parse AI tool arguments: {strict_error}; repair attempt failed: {lenient_error}") + }) + })?; return Ok((parsed, raw.to_string())); } if value.is_object() { @@ -654,7 +794,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let payload = openai_chat_payload(&request(dir.path().to_string_lossy().into_owned())); - let names = payload["tools"].as_array().unwrap().iter() + let names = payload["tools"] + .as_array() + .unwrap() + .iter() .filter_map(|tool| tool["function"]["name"].as_str()) .collect::>(); assert_eq!(names, vec![MAGIC_BRUSH_TOOL_NAME]); @@ -674,7 +817,11 @@ mod tests { let payload = openai_chat_payload(&request); - assert!(payload["tools"].as_array().unwrap().iter().any(|tool| tool["function"]["name"] == MAGIC_BRUSH_TOOL_NAME)); + assert!(payload["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["function"]["name"] == MAGIC_BRUSH_TOOL_NAME)); } #[test] @@ -711,8 +858,13 @@ mod tests { assert!(payload.get("tools").is_none()); assert_eq!(payload["stream"], true); - assert!(messages.iter().any(|message| message["content"].as_str().is_some_and(|content| content.contains("进入第五域 · 内置唤醒技能")))); - assert!(messages.iter().any(|message| message["content"].as_str().is_some_and(|content| content.contains("停在光之湖等待") && content.contains("不得替代第五域主仓库")))); + assert!(messages.iter().any(|message| message["content"] + .as_str() + .is_some_and(|content| content.contains("进入第五域 · 内置唤醒技能")))); + assert!(messages.iter().any(|message| message["content"] + .as_str() + .is_some_and(|content| content.contains("停在光之湖等待") + && content.contains("不得替代第五域主仓库")))); } #[test] @@ -806,7 +958,11 @@ mod tests { #[test] fn executes_safe_read_only_vault_tools() { let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("identity.md"), "# 身份记录\n\nICE-GL-TEST-001\n").unwrap(); + fs::write( + dir.path().join("identity.md"), + "# 身份记录\n\nICE-GL-TEST-001\n", + ) + .unwrap(); fs::write(dir.path().join("AGENTS.md"), "# Guidance\n").unwrap(); let request = request(dir.path().to_string_lossy().into_owned()); @@ -820,9 +976,18 @@ mod tests { "function": { "name": GET_NOTE_TOOL_NAME, "arguments": r#"{"path":"identity.md"}"# } })); - assert!(execute_openai_tool_calls(&request, &search, |_| {}).unwrap().unwrap().contains("identity.md")); - assert!(execute_openai_tool_calls(&request, &context, |_| {}).unwrap().unwrap().contains("AGENTS.md")); - assert!(execute_openai_tool_calls(&request, ¬e, |_| {}).unwrap().unwrap().contains("ICE-GL-TEST-001")); + assert!(execute_openai_tool_calls(&request, &search, |_| {}) + .unwrap() + .unwrap() + .contains("identity.md")); + assert!(execute_openai_tool_calls(&request, &context, |_| {}) + .unwrap() + .unwrap() + .contains("AGENTS.md")); + assert!(execute_openai_tool_calls(&request, ¬e, |_| {}) + .unwrap() + .unwrap() + .contains("ICE-GL-TEST-001")); } #[test] @@ -838,9 +1003,18 @@ mod tests { "function": { "name": DELETE_NOTE_TOOL_NAME, "arguments": r#"{"path":"remove.md"}"# } })); - assert!(execute_openai_tool_calls(&request, &edit, |_| {}).unwrap().unwrap().contains("已更新笔记")); - assert_eq!(fs::read_to_string(dir.path().join("memory.md")).unwrap(), "# New memory"); - assert!(execute_openai_tool_calls(&request, &delete, |_| {}).unwrap().unwrap().contains("已删除笔记")); + assert!(execute_openai_tool_calls(&request, &edit, |_| {}) + .unwrap() + .unwrap() + .contains("已更新笔记")); + assert_eq!( + fs::read_to_string(dir.path().join("memory.md")).unwrap(), + "# New memory" + ); + assert!(execute_openai_tool_calls(&request, &delete, |_| {}) + .unwrap() + .unwrap() + .contains("已删除笔记")); assert!(!dir.path().join("remove.md").exists()); } @@ -862,7 +1036,9 @@ mod tests { } })); - let summary = execute_openai_tool_calls(&request, &response, |_| {}).unwrap().unwrap(); + let summary = execute_openai_tool_calls(&request, &response, |_| {}) + .unwrap() + .unwrap(); assert!(summary.contains("神笔马良已完成")); assert!(dir.path().join("AI-MEMORY.md").is_file()); @@ -870,7 +1046,50 @@ mod tests { } #[test] - fn execute_openai_tool_calls_rejects_malformed_arguments() { + fn execute_openai_tool_calls_repairs_json5_arguments() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": "{path: 'generated.md', content: '# Generated\\n'}" + } + })); + + let summary = execute_openai_tool_calls(&request, &response, |_| {}) + .unwrap() + .unwrap(); + + assert!(summary.contains("generated.md")); + assert_eq!( + fs::read_to_string(dir.path().join("generated.md")).unwrap(), + "# Generated\n" + ); + } + + #[test] + fn execute_openai_tool_calls_repairs_fenced_arguments() { + let dir = tempfile::tempdir().unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let response = tool_call_response(json!({ + "function": { + "name": CREATE_NOTE_TOOL_NAME, + "arguments": r##"```json +{"path":"fenced.md","content":"# Fenced\n"} +```"## + } + })); + + execute_openai_tool_calls(&request, &response, |_| {}).unwrap(); + + assert_eq!( + fs::read_to_string(dir.path().join("fenced.md")).unwrap(), + "# Fenced\n" + ); + } + + #[test] + fn execute_openai_tool_calls_rejects_unrepairable_arguments() { let error = create_note_error(json!("{not-json")); assert!(error.contains("Failed to parse AI tool arguments")); diff --git a/src/components/MarkdownContent.test.tsx b/src/components/MarkdownContent.test.tsx index ffa586e..2b8b560 100644 --- a/src/components/MarkdownContent.test.tsx +++ b/src/components/MarkdownContent.test.tsx @@ -89,6 +89,21 @@ describe('MarkdownContent', () => { expect(mockOpenExternalUrl).not.toHaveBeenCalled() }) + it('routes relative note links through the active vault navigator', () => { + const onClick = vi.fn() + render( + , + ) + const link = screen.getByRole('link', { name: '第五域 · 永恒湖心系统' }) + + fireEvent.click(link) + + expect(onClick).toHaveBeenCalledWith('eternal-lake-heart/INDEX') + }) + it('renders GFM email autolinks when modern regex features are available', () => { render() const link = screen.getByRole('link', { name: 'luca@example.com' }) as HTMLAnchorElement diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx index 171ab9d..56371f3 100644 --- a/src/components/MarkdownContent.tsx +++ b/src/components/MarkdownContent.tsx @@ -27,6 +27,20 @@ function openExplicitWebUrl(event: MouseEvent, href: string) }) } +function localNoteTarget(href?: string): string | null { + const raw = href?.trim() + if (!raw || raw.startsWith('#') || raw.startsWith('/') || raw.startsWith('\\')) return null + if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return null + try { + return decodeURIComponent(raw) + .split(/[?#]/, 1)[0] + .replace(/^\.\//, '') + .replace(/\.(?:md|hdlp)$/i, '') || null + } catch { + return raw.replace(/^\.\//, '').replace(/\.(?:md|hdlp)$/i, '') || null + } +} + interface MarkdownContentProps { content: string onWikilinkClick?: (target: string) => void @@ -64,6 +78,22 @@ export const MarkdownContent = memo(function MarkdownContent({ content, onWikili if (isExplicitWebUrl(href)) { return openExplicitWebUrl(event, href)}>{children} } + const noteTarget = onWikilinkClick ? localNoteTarget(href) : null + if (noteTarget) { + return ( + { + event.preventDefault() + onWikilinkClick?.(noteTarget) + }} + > + {children} + + ) + } return {children} }, } diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index e86e3ba..59450e4 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -171,6 +171,17 @@ describe('useNoteActions hook', () => { expect(result.current.activeTabPath).toBe('/vault/target.md') }) + it('handleNavigateWikilink resolves an AI display label to its final route segment', async () => { + const target = makeEntry({ title: '永恒湖心系统', path: '/vault/eternal-lake-heart.md' }) + const { result } = renderHook(() => useNoteActions(makeConfig([target]))) + + await act(async () => { + result.current.handleNavigateWikilink('💠 第五域 · 永恒湖心系统') + }) + + expect(result.current.activeTabPath).toBe('/vault/eternal-lake-heart.md') + }) + it('handleNavigateWikilink warns when target not found', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 335a166..5b554b5 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -212,7 +212,11 @@ interface NavigateWikilinkParams { } function navigateWikilink({ entries, sourceEntry, target, selectNote }: NavigateWikilinkParams): void { - const found = resolveEntry(entries, target, sourceEntry) + const normalized = target.trim().replace(/\.(?:md|hdlp)$/i, '') + const withoutDecoration = normalized.replace(/^[^\p{L}\p{N}]+/u, '').trim() + const lastRouteSegment = withoutDecoration.split(/[·•]/).pop()?.trim() ?? '' + const candidates = [...new Set([normalized, withoutDecoration, lastRouteSegment].filter(Boolean))] + const found = candidates.map((candidate) => resolveEntry(entries, candidate, sourceEntry)).find(Boolean) if (found) selectNote(found) else console.warn(`Navigation target not found: ${target}`) }