fix: make vault tool calls and AI links reliable
This commit is contained in:
parent
adcdcaf4af
commit
f141317442
60
src-tauri/Cargo.lock
generated
60
src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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, ¬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"));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
},
|
||||
}
|
||||
|
||||
@ -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(() => {})
|
||||
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user