fix: make vault tool calls and AI links reliable

This commit is contained in:
冰朔 2026-07-21 21:19:58 +08:00
parent adcdcaf4af
commit f141317442
7 changed files with 392 additions and 52 deletions

60
src-tauri/Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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<serde_json::Value> {
[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<F>(
@ -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<String, String> {
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::<Vec<_>>().join("\n");
let results = response
.results
.into_iter()
.map(|result| {
format!(
"- [[{}]]\n 路径:{}\n 摘要:{}",
result.title, result.path, result.snippet
)
})
.collect::<Vec<_>>()
.join("\n");
Ok(format!("搜索“{query}”得到:\n{results}"))
}
fn vault_context_from_tool_args(request: &AiModelStreamRequest) -> Result<String, String> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<String>()))
Ok(format!(
"{}\n\n[内容过长,已截断]",
content
.chars()
.take(MAX_TOOL_NOTE_CHARS)
.collect::<String>()
))
}
fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result<String, String> {
@ -291,16 +371,23 @@ fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result<String, S
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|error| format!("Failed to create public reader: {error}"))?;
let response = client.get(url).send().map_err(|error| format!("Failed to read Guanghu URL: {error}"))?;
let response = client
.get(url)
.send()
.map_err(|error| format!("Failed to read Guanghu URL: {error}"))?;
if !response.status().is_success() {
return Err(format!("Guanghu URL returned {}.", response.status()));
}
let body = response.text().map_err(|error| format!("Failed to read Guanghu response: {error}"))?;
let body = response
.text()
.map_err(|error| format!("Failed to read Guanghu response: {error}"))?;
const MAX_PUBLIC_CHARS: usize = 24_000;
let clipped = body.chars().take(MAX_PUBLIC_CHARS).collect::<String>();
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<Vec<OpenAiToolCall>, String> {
@ -411,8 +540,19 @@ fn openai_tool_call(index: usize, call: &serde_json::Value) -> Result<OpenAiTool
fn parse_tool_arguments(value: &serde_json::Value) -> 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::<Vec<_>>();
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, &note, |_| {}).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, &note, |_| {})
.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"));

View File

@ -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(
<MarkdownContent
content="[第五域 · 永恒湖心系统](eternal-lake-heart/INDEX.md)"
onWikilinkClick={onClick}
/>,
)
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(<MarkdownContent content="Contact luca@example.com" />)
const link = screen.getByRole('link', { name: 'luca@example.com' }) as HTMLAnchorElement

View File

@ -27,6 +27,20 @@ function openExplicitWebUrl(event: MouseEvent<HTMLAnchorElement>, 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 <a href={href} onClick={(event) => openExplicitWebUrl(event, href)}>{children}</a>
}
const noteTarget = onWikilinkClick ? localNoteTarget(href) : null
if (noteTarget) {
return (
<a
href={href}
className="chat-wikilink border-0 bg-transparent p-0"
data-wikilink-target={noteTarget}
onClick={(event) => {
event.preventDefault()
onWikilinkClick?.(noteTarget)
}}
>
{children}
</a>
)
}
return <a href={href}>{children}</a>
},
}

View File

@ -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(() => {})

View File

@ -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}`)
}