From 46d287510df64219bb6f3886c7d76133b4f91b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Wed, 22 Jul 2026 16:16:26 +0800 Subject: [PATCH] feat: add web research and visible agent activity --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 1 + .../skills/vault-tool-operations/SKILL.md | 3 + src-tauri/src/ai_model_tools.rs | 441 +++++++++++++++++- src-tauri/src/ai_models.rs | 218 ++++++++- src/components/AiActionCard.tsx | 14 +- src/components/AiMessage.test.tsx | 1 + src/components/AiMessage.tsx | 15 +- src/components/HoloLakeHome.test.tsx | 11 + src/components/HoloLakeHome.tsx | 34 ++ src/lib/aiAgentConversation.test.ts | 34 ++ src/lib/aiAgentMessageState.ts | 72 ++- src/lib/aiAgentStreamCallbacks.ts | 10 +- src/utils/ai-agent.test.ts | 7 +- src/utils/ai-agent.ts | 4 +- 15 files changed, 821 insertions(+), 46 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3044457..61c914d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1881,6 +1881,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "objc2-web-kit", + "quick-xml", "regex", "reqwest 0.12.28", "sentry", @@ -3691,6 +3692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", + "serde", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ea1afef..b35bfd7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,6 +48,7 @@ sentry = "0.37" uuid = { version = "1", features = ["v4"] } tempfile = "3" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +quick-xml = { version = "0.38", features = ["serialize"] } tauri-plugin-deep-link = "2.4.9" tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } diff --git a/src-tauri/resources/skills/vault-tool-operations/SKILL.md b/src-tauri/resources/skills/vault-tool-operations/SKILL.md index 4e8d52e..a00d446 100644 --- a/src-tauri/resources/skills/vault-tool-operations/SKILL.md +++ b/src-tauri/resources/skills/vault-tool-operations/SKILL.md @@ -8,6 +8,8 @@ Use `magic_brush` as the executable skill for work inside the active vault. Do n - `get_vault_context`: no arguments - `get_note`: `path` - `read_guanghu_url`: `url` +- `search_web`: `query`; use it when the user asks for current or external information and has not supplied a URL +- `read_web_page`: public HTTPS `url`; use it to verify relevant search results before drawing conclusions - `create_note`: `path`, plus `content` whenever the user requested body text; `title` may be used only for a heading-only note - `edit_note`: `path` and the complete replacement `content` - `delete_note`: `path`; only after the user explicitly confirms the exact deletion @@ -21,3 +23,4 @@ Use `magic_brush` as the executable skill for work inside the active vault. Do n 5. Report each completed step from the tool results. If a step fails, state the exact failed tool and missing or invalid argument; do not claim later steps succeeded. 6. Do not repeat a step whose successful result is already present in the conversation. 7. Ask for confirmation immediately before permanent deletion, even if earlier read/create/edit steps have already completed. +8. For web research, search first, read the most relevant result pages, distinguish page claims from verified facts, and include the source links in the final answer. Never claim that search snippets alone prove the page contents. diff --git a/src-tauri/src/ai_model_tools.rs b/src-tauri/src/ai_model_tools.rs index 11de5fb..5d86419 100644 --- a/src-tauri/src/ai_model_tools.rs +++ b/src-tauri/src/ai_model_tools.rs @@ -1,5 +1,7 @@ use crate::ai_agents::AiAgentStreamEvent; -use crate::ai_models::{AiModelProviderKind, AiModelStreamRequest}; +use crate::ai_models::AiModelStreamRequest; +use serde::Deserialize; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; const CREATE_NOTE_TOOL_NAME: &str = "create_note"; @@ -9,6 +11,8 @@ const GET_NOTE_TOOL_NAME: &str = "get_note"; const EDIT_NOTE_TOOL_NAME: &str = "edit_note"; const DELETE_NOTE_TOOL_NAME: &str = "delete_note"; const READ_GUANGHU_URL_TOOL_NAME: &str = "read_guanghu_url"; +const SEARCH_WEB_TOOL_NAME: &str = "search_web"; +const READ_WEB_PAGE_TOOL_NAME: &str = "read_web_page"; 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 = @@ -36,8 +40,8 @@ const MAGIC_BRUSH_TOOL_JSON: &str = r#"{ "properties": { "tool": { "type": "string", - "description": "Operation and required arguments: search_notes(query); get_note(path); read_guanghu_url(url); create_note(path, content or title); edit_note(path, complete content); delete_note(path); get_vault_context() and get_fifth_domain_wake_route() need no arguments.", - "enum": ["get_fifth_domain_wake_route", "search_notes", "get_vault_context", "get_note", "read_guanghu_url", "create_note", "edit_note", "delete_note"] + "description": "Operation and required arguments: search_web(query) finds public web pages; read_web_page(url) reads one public HTTPS result; search_notes(query); get_note(path); read_guanghu_url(url); create_note(path, content or title); edit_note(path, complete content); delete_note(path); get_vault_context() and get_fifth_domain_wake_route() need no arguments.", + "enum": ["get_fifth_domain_wake_route", "search_web", "read_web_page", "search_notes", "get_vault_context", "get_note", "read_guanghu_url", "create_note", "edit_note", "delete_note"] }, "arguments": { "type": "object", @@ -67,6 +71,11 @@ struct OpenAiToolResult { output: String, } +pub(crate) struct AnthropicToolExecution { + pub id: String, + pub result: Result, +} + impl OpenAiToolResult { fn from_summary(summary: String) -> Self { Self { @@ -90,6 +99,34 @@ pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json: payload } +pub(crate) fn anthropic_tools(request: &AiModelStreamRequest) -> Vec { + if !should_offer_openai_tools(request) { + return Vec::new(); + } + openai_vault_tools() + .into_iter() + .filter_map(|tool| { + let function = tool.get("function")?; + Some(serde_json::json!({ + "name": function.get("name")?, + "description": function.get("description")?, + "input_schema": function.get("parameters")?, + })) + }) + .collect() +} + +pub(crate) fn anthropic_system_context(request: &AiModelStreamRequest) -> Option { + let mut context = Vec::new(); + if let Some(route) = conditional_route_context(&request.message) { + context.push(route); + } + if !anthropic_tools(request).is_empty() { + context.push(VAULT_TOOL_OPERATIONS_SKILL); + } + (!context.is_empty()).then(|| context.join("\n\n")) +} + fn selected_model_supports_streaming(request: &AiModelStreamRequest) -> bool { request .provider @@ -124,6 +161,54 @@ pub(crate) fn openai_response_has_tool_calls(json: &serde_json::Value) -> bool { .is_some_and(|calls| !calls.is_empty()) } +pub(crate) fn anthropic_response_has_tool_calls(json: &serde_json::Value) -> bool { + json["content"] + .as_array() + .is_some_and(|content| content.iter().any(|block| block["type"] == "tool_use")) +} + +pub(crate) fn execute_anthropic_tool_calls( + request: &AiModelStreamRequest, + json: &serde_json::Value, + mut emit: F, +) -> Result, String> +where + F: FnMut(AiAgentStreamEvent), +{ + let content = json["content"] + .as_array() + .ok_or_else(|| "Anthropic tool response did not include content blocks.".to_string())?; + let mut executions = Vec::new(); + for (index, block) in content.iter().enumerate() { + if block["type"] != "tool_use" { + continue; + } + let id = block["id"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| format!("anthropic_tool_{index}")); + let name = block["name"] + .as_str() + .ok_or_else(|| "Anthropic tool call did not include a name.".to_string())? + .to_string(); + let arguments = block + .get("input") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let call = OpenAiToolCall { + id: id.clone(), + name, + raw_arguments: arguments.to_string(), + arguments, + }; + executions.push(AnthropicToolExecution { + id, + result: execute_openai_tool_call_with_events(request, &call, &mut emit), + }); + } + Ok(executions) +} + fn openai_chat_messages( request: &AiModelStreamRequest, offers_tools: bool, @@ -155,10 +240,7 @@ fn conditional_route_context(message: &str) -> Option<&'static str> { fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool { let has_active_vault = non_empty_option(request.vault_path.as_deref()).is_some(); - has_active_vault - && message_needs_magic_brush(&request.message) - && (request.provider.kind == AiModelProviderKind::OpenAi - || selected_model_supports_tools(request)) + has_active_vault && message_needs_magic_brush(&request.message) } fn message_needs_magic_brush(message: &str) -> bool { @@ -192,6 +274,11 @@ fn message_needs_magic_brush(message: &str) -> bool { "file", "vault", "https://guanghulab.com/", + "web", + "internet", + "online", + "research", + "latest", "搜索", "查找", "读取", @@ -210,6 +297,14 @@ fn message_needs_magic_brush(message: &str) -> bool { "文件", "知识库", "路径", + "联网", + "上网", + "网上", + "网络", + "网页", + "调研", + "查资料", + "最新", ]; if operation_words.iter().any(|word| trimmed.contains(word)) { return true; @@ -224,15 +319,6 @@ fn message_needs_magic_brush(message: &str) -> bool { .any(|word| message.to_lowercase().contains(word)) } -fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool { - request - .provider - .models - .iter() - .find(|model| model.id == request.model_id) - .is_some_and(|model| model.capabilities.tools) -} - fn openai_vault_tools() -> Vec { [MAGIC_BRUSH_TOOL_JSON] .into_iter() @@ -303,6 +389,8 @@ where GET_VAULT_CONTEXT_TOOL_NAME => vault_context_from_tool_args(request)?, GET_NOTE_TOOL_NAME => get_note_from_tool_args(request, &tool_call.arguments)?, READ_GUANGHU_URL_TOOL_NAME => read_guanghu_url_from_tool_args(&tool_call.arguments)?, + SEARCH_WEB_TOOL_NAME => search_web_from_tool_args(&tool_call.arguments)?, + READ_WEB_PAGE_TOOL_NAME => read_web_page_from_tool_args(&tool_call.arguments)?, EDIT_NOTE_TOOL_NAME => edit_note_from_tool_args(request, &tool_call.arguments)?, DELETE_NOTE_TOOL_NAME => delete_note_from_tool_args(request, &tool_call.arguments)?, MAGIC_BRUSH_TOOL_NAME => run_magic_brush_from_args(request, &tool_call.arguments, emit)?, @@ -430,6 +518,237 @@ fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result, +} + +#[derive(Debug, Deserialize)] +struct WebSearchItem { + #[serde(default)] + title: String, + #[serde(default)] + link: String, + #[serde(default)] + description: String, +} + +fn search_web_from_tool_args(args: &serde_json::Value) -> Result { + let query = required_tool_string(args, SEARCH_WEB_TOOL_NAME, "query")?; + let limit = args + .get("limit") + .and_then(serde_json::Value::as_u64) + .unwrap_or(6) + .clamp(1, 10) as usize; + let mut url = reqwest::Url::parse("https://www.bing.com/search") + .map_err(|error| format!("Failed to prepare web search: {error}"))?; + url.query_pairs_mut() + .append_pair("format", "rss") + .append_pair("q", query); + let body = public_web_get(url) + .map_err(|error| format!("联网搜索失败:{error}"))? + .error_for_status() + .map_err(|error| format!("联网搜索服务返回错误:{error}"))? + .text() + .map_err(|error| format!("读取联网搜索结果失败:{error}"))?; + let rss: WebSearchRss = + quick_xml::de::from_str(&body).map_err(|error| format!("解析联网搜索结果失败:{error}"))?; + format_web_search_results(query, rss.channel.item, limit) +} + +fn format_web_search_results( + query: &str, + items: Vec, + limit: usize, +) -> Result { + let results = items + .into_iter() + .filter(|item| !item.title.trim().is_empty() && !item.link.trim().is_empty()) + .take(limit) + .enumerate() + .map(|(index, item)| { + format!( + "{}. {}\n 链接:{}\n 摘要:{}", + index + 1, + plain_web_text(&item.title), + item.link.trim(), + plain_web_text(&item.description), + ) + }) + .collect::>(); + if results.is_empty() { + return Ok(format!("联网搜索“{query}”没有返回可用页面。")); + } + Ok(format!( + "联网搜索“{query}”得到以下页面。需要核实内容时,请继续调用 read_web_page 读取相关链接:\n{}", + results.join("\n") + )) +} + +fn read_web_page_from_tool_args(args: &serde_json::Value) -> Result { + let raw_url = required_tool_string(args, READ_WEB_PAGE_TOOL_NAME, "url")?; + let url = reqwest::Url::parse(raw_url).map_err(|error| format!("无效网页地址:{error}"))?; + validate_public_https_url(&url)?; + let response = public_web_get(url.clone()) + .map_err(|error| format!("读取网页失败:{error}"))? + .error_for_status() + .map_err(|error| format!("网页返回错误:{error}"))?; + let final_url = response.url().clone(); + validate_public_https_url(&final_url)?; + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + let body = response + .text() + .map_err(|error| format!("读取网页正文失败:{error}"))?; + const MAX_WEB_PAGE_CHARS: usize = 30_000; + let readable = if content_type.contains("html") || body.trim_start().starts_with('<') { + plain_web_text(&body) + } else { + body + }; + let clipped = readable + .chars() + .take(MAX_WEB_PAGE_CHARS) + .collect::(); + let suffix = if readable.chars().count() > MAX_WEB_PAGE_CHARS { + "\n\n[网页内容过长,已截断]" + } else { + "" + }; + Ok(format!("网页:{final_url}\n\n{clipped}{suffix}")) +} + +fn public_web_get(mut url: reqwest::Url) -> Result { + const MAX_REDIRECTS: usize = 5; + for _ in 0..=MAX_REDIRECTS { + validate_public_https_url(&url)?; + let resolved = resolve_public_web_host(&url)?; + let mut builder = reqwest::blocking::Client::builder() + .user_agent("HoloLake-Era/0.1.8 public-research-tool") + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)); + if let (Some(host), Some(address)) = (url.host_str(), resolved.first()) { + let ip_host = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + if ip_host.parse::().is_err() { + builder = builder.resolve(host, *address); + } + } + let response = builder + .build() + .map_err(|error| format!("无法创建公共网页读取器:{error}"))? + .get(url.clone()) + .send() + .map_err(|error| format!("公共网页请求失败:{error}"))?; + if !response.status().is_redirection() { + return Ok(response); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "网页返回了没有目标地址的重定向。".to_string())?; + url = url + .join(location) + .map_err(|error| format!("网页重定向地址无效:{error}"))?; + } + Err(format!("网页重定向超过 {MAX_REDIRECTS} 次,已停止读取。")) +} + +fn resolve_public_web_host(url: &reqwest::Url) -> Result, String> { + let host = url + .host_str() + .ok_or_else(|| "网页地址缺少主机名。".to_string())?; + let addresses = (host, url.port_or_known_default().unwrap_or(443)) + .to_socket_addrs() + .map_err(|error| format!("无法解析网页主机:{error}"))? + .collect::>(); + if addresses.is_empty() { + return Err("网页主机没有可用的公共地址。".into()); + } + if addresses + .iter() + .any(|address| !is_public_web_ip(address.ip())) + { + return Err("联网工具不能访问解析到本机、局域网或保留地址的主机。".into()); + } + Ok(addresses) +} + +fn validate_public_https_url(url: &reqwest::Url) -> Result<(), String> { + if url.scheme() != "https" { + return Err("联网工具只允许读取公共 HTTPS 网页。".into()); + } + let host = url + .host_str() + .ok_or_else(|| "网页地址缺少主机名。".to_string())? + .trim_end_matches('.') + .to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") { + return Err("联网工具不能访问本机或局域网地址。".into()); + } + let ip_host = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(&host); + if let Ok(ip) = ip_host.parse::() { + if !is_public_web_ip(ip) { + return Err("联网工具不能访问本机、局域网或保留地址。".into()); + } + } + Ok(()) +} + +fn is_public_web_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + !(ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_multicast() + || ip.is_unspecified() + || ip.is_documentation()) + } + IpAddr::V6(ip) => { + let first = ip.segments()[0]; + let unique_local = first & 0xfe00 == 0xfc00; + let link_local = first & 0xffc0 == 0xfe80; + !(ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || unique_local + || link_local) + } + } +} + +fn plain_web_text(raw: &str) -> String { + let without_hidden = + regex::Regex::new(r"(?is)<(?:script|style|noscript)[^>]*>.*?") + .expect("hidden web content regex must compile") + .replace_all(raw, " "); + let without_tags = regex::Regex::new(r"(?s)<[^>]+>") + .expect("HTML tag regex must compile") + .replace_all(&without_hidden, " "); + let decoded = quick_xml::escape::unescape(&without_tags) + .map(|text| text.into_owned()) + .unwrap_or_else(|_| without_tags.into_owned()); + decoded.split_whitespace().collect::>().join(" ") +} + fn resolve_existing_vault_note( request: &AiModelStreamRequest, raw_path: &str, @@ -508,6 +827,8 @@ where GET_VAULT_CONTEXT_TOOL_NAME, GET_NOTE_TOOL_NAME, READ_GUANGHU_URL_TOOL_NAME, + SEARCH_WEB_TOOL_NAME, + READ_WEB_PAGE_TOOL_NAME, CREATE_NOTE_TOOL_NAME, EDIT_NOTE_TOOL_NAME, DELETE_NOTE_TOOL_NAME, @@ -715,6 +1036,7 @@ mod tests { use super::*; use crate::ai_models::{ AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider, + AiModelProviderKind, }; use serde_json::json; use std::fs; @@ -868,6 +1190,28 @@ mod tests { .any(|tool| tool["function"]["name"] == MAGIC_BRUSH_TOOL_NAME)); } + #[test] + fn anthropic_payload_uses_native_tool_schema_and_shared_skill() { + let dir = tempfile::tempdir().unwrap(); + let mut request = request_with_provider( + provider(), + Some(dir.path().to_string_lossy().into_owned()), + vec![], + ); + request.provider.kind = crate::ai_models::AiModelProviderKind::Anthropic; + request.message = "请联网搜索今天的公开资料。".into(); + + let tools = anthropic_tools(&request); + + assert_eq!(tools[0]["name"], MAGIC_BRUSH_TOOL_NAME); + assert!(tools[0]["input_schema"].is_object()); + assert!( + anthropic_system_context(&request).is_some_and(|context| context + .contains("search_web") + && context.contains("read_web_page")) + ); + } + #[test] fn openai_payload_skips_create_note_when_no_active_vault_is_loaded() { let payload = openai_chat_payload(&request_with_provider(provider(), None, Vec::new())); @@ -1025,6 +1369,38 @@ mod tests { )); } + #[test] + fn parses_and_executes_anthropic_native_tool_calls() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("page.md"), "# Anthropic tool result\n").unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let response = json!({ + "content": [{ + "type": "tool_use", + "id": "toolu_read", + "name": MAGIC_BRUSH_TOOL_NAME, + "input": { + "purpose": "read the page", + "steps": [{ "tool": GET_NOTE_TOOL_NAME, "arguments": { "path": "page.md" } }] + } + }] + }); + let mut events = Vec::new(); + + let executions = + execute_anthropic_tool_calls(&request, &response, |event| events.push(event)).unwrap(); + + assert_eq!(executions.len(), 1); + assert_eq!(executions[0].id, "toolu_read"); + assert!(executions[0] + .result + .as_ref() + .is_ok_and(|result| result.contains("Anthropic tool result"))); + assert!(events + .iter() + .any(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. }))); + } + #[test] fn executes_safe_read_only_vault_tools() { let dir = tempfile::tempdir().unwrap(); @@ -1168,6 +1544,39 @@ mod tests { assert!(error.contains("edit_note requires content")); } + #[test] + fn web_search_results_are_readable_and_keep_source_links() { + let items = vec![WebSearchItem { + title: "光湖 & HoloLake".into(), + link: "https://example.com/hololake".into(), + description: "

公开页面 摘要

".into(), + }]; + + let result = format_web_search_results("光湖", items, 6).unwrap(); + + assert!(result.contains("光湖 & HoloLake")); + assert!(result.contains("https://example.com/hololake")); + assert!(result.contains("公开页面 摘要")); + assert!(result.contains("read_web_page")); + } + + #[test] + fn public_web_reader_rejects_local_and_insecure_addresses() { + for url in [ + "http://example.com", + "https://localhost/private", + "https://127.0.0.1/private", + "https://192.168.1.20/private", + "https://[::1]/private", + ] { + assert!(validate_public_https_url(&reqwest::Url::parse(url).unwrap()).is_err()); + } + assert!(validate_public_https_url( + &reqwest::Url::parse("https://example.com/public").unwrap() + ) + .is_ok()); + } + #[test] fn execute_openai_tool_calls_repairs_json5_arguments() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/ai_models.rs b/src-tauri/src/ai_models.rs index 428b602..76728f9 100644 --- a/src-tauri/src/ai_models.rs +++ b/src-tauri/src/ai_models.rs @@ -201,7 +201,7 @@ where F: FnMut(AiAgentStreamEvent), { match request.provider.kind { - AiModelProviderKind::Anthropic => send_anthropic_message(request), + AiModelProviderKind::Anthropic => send_anthropic_message(request, emit), _ => send_openai_compatible_message(request, emit), } } @@ -249,7 +249,10 @@ fn tool_choice_compatibility_error(error: &str) -> bool { normalized.contains("tool_choice") && (normalized.contains("does not support") || normalized.contains("unsupported") - || normalized.contains("not supported")) + || normalized.contains("not supported") + || normalized.contains("unknown field") + || normalized.contains("unrecognized") + || normalized.contains("invalid parameter")) } fn run_openai_agent_loop( @@ -344,18 +347,26 @@ where .timeout(std::time::Duration::from_secs(120)) .build() .map_err(|error| format!("Failed to create HTTP client: {error}"))?; - let builder = apply_provider_headers(apply_auth_headers(client.post(endpoint).json(&payload), request)?, request); + let builder = apply_provider_headers( + apply_auth_headers(client.post(endpoint).json(&payload), request)?, + request, + ); let response = send_provider_request(builder)?; let status = response.status(); if !status.is_success() { let text = response.text().unwrap_or_default(); - return Err(format!("AI provider returned {status}: {}", truncate_error(&text))); + return Err(format!( + "AI provider returned {status}: {}", + truncate_error(&text) + )); } let mut full_text = String::new(); for line in BufReader::new(response).lines() { let line = line.map_err(|error| format!("Failed to read AI provider stream: {error}"))?; - let Some(delta) = openai_sse_text_delta(&line)? else { continue }; + let Some(delta) = openai_sse_text_delta(&line)? else { + continue; + }; full_text.push_str(&delta); emit(AiAgentStreamEvent::TextDelta { text: delta }); } @@ -367,14 +378,23 @@ where } fn openai_sse_text_delta(line: &str) -> Result, String> { - let Some(data) = line.strip_prefix("data:").map(str::trim) else { return Ok(None) }; - if data.is_empty() || data == "[DONE]" { return Ok(None) } + let Some(data) = line.strip_prefix("data:").map(str::trim) else { + return Ok(None); + }; + if data.is_empty() || data == "[DONE]" { + return Ok(None); + } let json: serde_json::Value = serde_json::from_str(data) .map_err(|error| format!("Failed to parse AI provider stream event: {error}"))?; - Ok(json["choices"][0]["delta"]["content"].as_str().map(str::to_string)) + Ok(json["choices"][0]["delta"]["content"] + .as_str() + .map(str::to_string)) } -fn send_anthropic_message(request: &AiModelStreamRequest) -> Result { +fn send_anthropic_message(request: &AiModelStreamRequest, emit: &mut F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ let endpoint = format!("{}/messages", normalized_base_url(request)?); let mut payload = serde_json::json!({ "model": request.model_id, @@ -382,14 +402,97 @@ fn send_anthropic_message(request: &AiModelStreamRequest) -> Result>() + .join("\n\n"); + if !system.is_empty() { + payload["system"] = serde_json::Value::String(system); + } + + let tools = crate::ai_model_tools::anthropic_tools(request); + if !tools.is_empty() { + payload["tools"] = serde_json::Value::Array(tools); + return run_anthropic_agent_loop(request, payload, emit, |attempt| { + send_json_request(request, endpoint.clone(), attempt) + }); } let json = send_json_request(request, endpoint, payload)?; extract_anthropic_text(&json) } +fn run_anthropic_agent_loop( + request: &AiModelStreamRequest, + mut payload: serde_json::Value, + emit: &mut F, + mut send: S, +) -> Result +where + F: FnMut(AiAgentStreamEvent), + S: FnMut(serde_json::Value) -> Result, +{ + const MAX_TOOL_ROUNDS: usize = 8; + + for round in 1..=MAX_TOOL_ROUNDS { + let json = send(payload.clone())?; + if !crate::ai_model_tools::anthropic_response_has_tool_calls(&json) { + return extract_anthropic_text(&json); + } + let executions = + crate::ai_model_tools::execute_anthropic_tool_calls(request, &json, &mut *emit)?; + append_anthropic_agent_round(&mut payload, &json, &executions)?; + if round == MAX_TOOL_ROUNDS { + return Err(format!( + "Agent stopped after {MAX_TOOL_ROUNDS} tool rounds to prevent an infinite loop." + )); + } + let failed = executions.iter().any(|execution| execution.result.is_err()); + emit(AiAgentStreamEvent::ThinkingDelta { + text: if failed { + format!("第 {round} 轮工具参数有误,正在修正后继续……") + } else { + format!("第 {round} 轮工具执行完成,正在根据结果继续处理……") + }, + }); + } + unreachable!("bounded Anthropic agent loop always returns") +} + +fn append_anthropic_agent_round( + payload: &mut serde_json::Value, + response: &serde_json::Value, + executions: &[crate::ai_model_tools::AnthropicToolExecution], +) -> Result<(), String> { + let messages = payload["messages"] + .as_array_mut() + .ok_or_else(|| "Anthropic agent payload did not include a messages array.".to_string())?; + messages.push(serde_json::json!({ + "role": "assistant", + "content": response["content"].clone(), + })); + let results = executions + .iter() + .map(|execution| match &execution.result { + Ok(output) => serde_json::json!({ + "type": "tool_result", + "tool_use_id": execution.id, + "content": format!("{output}\n\nIf the task is unfinished, call the next tool now. Only give the final answer after every requested operation has an explicit successful result."), + }), + Err(error) => serde_json::json!({ + "type": "tool_result", + "tool_use_id": execution.id, + "content": format!("Tool failed: {error}\n\nCorrect the arguments and call the tool again. Do not claim this operation succeeded."), + "is_error": true, + }), + }) + .collect::>(); + messages.push(serde_json::json!({ "role": "user", "content": results })); + Ok(()) +} + fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 { request .provider @@ -962,6 +1065,17 @@ mod tests { assert!(attempts[1]["tools"].is_array()); } + #[test] + fn tool_choice_fallback_is_provider_agnostic() { + for error in [ + "unknown field tool_choice", + "tool_choice is unsupported by this endpoint", + "invalid parameter: tool_choice", + ] { + assert!(tool_choice_compatibility_error(error)); + } + } + #[test] fn agent_loop_reads_then_edits_before_returning_final_text() { let dir = tempfile::tempdir().unwrap(); @@ -1018,13 +1132,83 @@ mod tests { .unwrap(); assert_eq!(answer, "Verified complete."); - assert_eq!(fs::read_to_string(dir.path().join("page.md")).unwrap(), "# After\n\n[[Linked page]]"); + assert_eq!( + fs::read_to_string(dir.path().join("page.md")).unwrap(), + "# After\n\n[[Linked page]]" + ); assert_eq!(sent_payloads.len(), 3); - assert!(sent_payloads[1]["messages"].as_array().unwrap().last().unwrap()["content"] - .as_str().unwrap().contains("# Before")); - assert!(sent_payloads[2]["messages"].as_array().unwrap().last().unwrap()["content"] - .as_str().unwrap().contains("已更新笔记")); - assert!(events.iter().filter(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. })).count() >= 4); + assert!(sent_payloads[1]["messages"] + .as_array() + .unwrap() + .last() + .unwrap()["content"] + .as_str() + .unwrap() + .contains("# Before")); + assert!(sent_payloads[2]["messages"] + .as_array() + .unwrap() + .last() + .unwrap()["content"] + .as_str() + .unwrap() + .contains("已更新笔记")); + assert!( + events + .iter() + .filter(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. })) + .count() + >= 4 + ); + } + + #[test] + fn anthropic_agent_loop_returns_native_tool_results_before_continuing() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("page.md"), "# Native Claude result\n").unwrap(); + let mut request = request(provider(AiModelProviderKind::Anthropic)); + request.vault_path = Some(dir.path().to_string_lossy().into_owned()); + request.message = "Read page.md".into(); + let mut responses = vec![ + json!({ + "content": [{ + "type": "tool_use", + "id": "toolu_1", + "name": "magic_brush", + "input": { + "purpose": "read the requested page", + "steps": [{ "tool": "get_note", "arguments": { "path": "page.md" } }] + } + }] + }), + json!({ "content": [{ "type": "text", "text": "Read and verified." }] }), + ]; + let mut sent = Vec::new(); + let mut events = Vec::new(); + + let answer = run_anthropic_agent_loop( + &request, + json!({ + "messages": [{ "role": "user", "content": "Read page.md" }], + "tools": [{ "name": "magic_brush" }] + }), + &mut |event| events.push(event), + |payload| { + sent.push(payload); + Ok(responses.remove(0)) + }, + ) + .unwrap(); + + assert_eq!(answer, "Read and verified."); + assert_eq!(sent.len(), 2); + let tool_result = sent[1]["messages"].as_array().unwrap().last().unwrap(); + assert!(tool_result["content"][0]["content"] + .as_str() + .is_some_and(|content| content.contains("Native Claude result"))); + assert!(events + .iter() + .any(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. }))); } #[test] diff --git a/src/components/AiActionCard.tsx b/src/components/AiActionCard.tsx index 3add405..828e961 100644 --- a/src/components/AiActionCard.tsx +++ b/src/components/AiActionCard.tsx @@ -4,6 +4,7 @@ import { CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown, Terminal, File, FolderOpen, NotePencil, } from '@phosphor-icons/react' +import type { AppLocale } from '../lib/i18n' export type AiActionStatus = 'pending' | 'done' | 'error' @@ -17,6 +18,7 @@ export interface AiActionCardProps { expanded: boolean onToggle: () => void onOpenNote?: (path: string) => void + locale?: AppLocale } const MAX_DETAIL_LENGTH = 800 @@ -38,6 +40,9 @@ const TOOL_ICON_MAP: Record = { Grep: (s) => , // Tolaria MCP tools search_notes: (s) => , + search_web: (s) => , + read_web_page: (s) => , + read_guanghu_url: (s) => , get_vault_context: (s) => , get_note: (s) => , open_note: (s) => , @@ -183,12 +188,14 @@ function ActionCardDetails({ input, output, status, + locale = 'en', }: { expanded: boolean hasDetails: boolean input?: string output?: string status: AiActionStatus + locale?: AppLocale }) { if (!expanded || !hasDetails) return null @@ -198,16 +205,16 @@ function ActionCardDetails({ data-testid="action-card-details" style={{ padding: '0 10px 8px 10px' }} > - {formattedInput && } + {formattedInput && } {output && ( - + )} ) } export function AiActionCard({ - tool, label, path, status, input, output, expanded, onToggle, onOpenNote, + tool, label, path, status, input, output, expanded, onToggle, onOpenNote, locale, }: AiActionCardProps) { const renderIcon = TOOL_ICON_BY_NAME.get(tool) ?? DEFAULT_ICON const hasDetails = hasActionDetails(input, output) @@ -256,6 +263,7 @@ export function AiActionCard({ input={input} output={output} status={status} + locale={locale} /> ) diff --git a/src/components/AiMessage.test.tsx b/src/components/AiMessage.test.tsx index e7c053a..eec1e06 100644 --- a/src/components/AiMessage.test.tsx +++ b/src/components/AiMessage.test.tsx @@ -109,6 +109,7 @@ describe('AiMessage', () => { expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'false') expect(screen.getByTestId('tool-use-count').textContent).toBe('2') expect(screen.getByTestId('tool-use-count')).toHaveAttribute('data-pending', 'true') + expect(screen.getByTestId('tool-use-current-action')).toHaveTextContent('Searched') expect(screen.queryByTestId('ai-action-card')).toBeNull() fireEvent.click(screen.getByTestId('tool-use-toggle')) diff --git a/src/components/AiMessage.tsx b/src/components/AiMessage.tsx index 2897941..7cc6112 100644 --- a/src/components/AiMessage.tsx +++ b/src/components/AiMessage.tsx @@ -146,11 +146,12 @@ function ReasoningBlock({ locale, text, expanded, onToggle }: { ) } -function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: { +function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand, locale }: { actions: AiAction[] onOpenNote?: (path: string) => void expandedIds: Set onToggleExpand: (toolId: string) => void + locale?: AppLocale }) { return (
@@ -166,6 +167,7 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: { expanded={expandedIds.has(action.toolId)} onToggle={() => onToggleExpand(action.toolId)} onOpenNote={onOpenNote} + locale={locale} /> ))}
@@ -190,6 +192,7 @@ function ToolUseBlock({ onToggleAction: (toolId: string) => void }) { const pending = actions.some((action) => action.status === 'pending') + const latestAction = actions.at(-1) return (
@@ -203,6 +206,15 @@ function ToolUseBlock({ > {translate(locale, 'ai.message.toolUse')} + {latestAction && ( + + {latestAction.label} + + )}
)} diff --git a/src/components/HoloLakeHome.test.tsx b/src/components/HoloLakeHome.test.tsx index 6c5b64f..0f7cb08 100644 --- a/src/components/HoloLakeHome.test.tsx +++ b/src/components/HoloLakeHome.test.tsx @@ -46,6 +46,17 @@ describe('HoloLakeHome', () => { expect(screen.getByRole('heading', { name: '爱之核心子系统' })).toBeInTheDocument() }) + it('shows the user-facing 0.1.8 release notes and capability boundary', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: '查看 0.1.8 更新说明' })) + + expect(screen.getByRole('dialog', { name: 'HoloLake Era 0.1.8 · 本次更新' })).toBeInTheDocument() + expect(screen.getByText('主动联网调研')).toBeInTheDocument() + expect(screen.getByText('多模型 API 兼容')).toBeInTheDocument() + expect(screen.getByText(/不等同于拥有终端、代码工程和系统权限的完整编程 Agent/)).toBeInTheDocument() + }) + it('keeps Zhizhi inside Eternal Lake Heart and routes to Tomorrow channel', () => { render() diff --git a/src/components/HoloLakeHome.tsx b/src/components/HoloLakeHome.tsx index 5d9a254..f332ffc 100644 --- a/src/components/HoloLakeHome.tsx +++ b/src/components/HoloLakeHome.tsx @@ -67,6 +67,7 @@ const registeredServers = [ export function HoloLakeHome({ locale, onEnterKnowledgeBase, onOpenLocalWorkspace }: HoloLakeHomeProps) { const [route, setRoute] = useState('zero-core') const [architectureOpen, setArchitectureOpen] = useState(false) + const [releaseNotesOpen, setReleaseNotesOpen] = useState(false) const [fifthDomainConnection, setFifthDomainConnection] = useState< { status: 'checking' | 'error' } | { status: 'connected'; discovery: FifthDomainDiscovery } >({ status: 'checking' }) @@ -119,6 +120,7 @@ export function HoloLakeHome({ locale, onEnterKnowledgeBase, onOpenLocalWorkspac

{t('hololake.channel.zeroCoreTitle')}

{t('hololake.channel.zeroCoreDescription')}

+ ) } @@ -270,6 +272,38 @@ export function HoloLakeHome({ locale, onEnterKnowledgeBase, onOpenLocalWorkspac

GLW-ENTRY-001 → FD-LANGUAGE-001 → TCS-ROOT-001 → GLS-SYS-ARCH-001 → ELH-LAMP-001

+ + + + + HoloLake Era 0.1.8 · 本次更新 + 更可靠的内置 AI、可验证的工具执行,以及更自然的人格表达。 + +
+
+

内置知识库 Agent

+

AI 可以连续搜索、读取、新建、写入、编辑和删除页面,并根据每一步真实回执继续处理,不再把“准备操作”误报成“已经完成”。

+
+
+

主动联网调研

+

新增公开网页搜索与页面读取。AI 可主动查找资料、打开相关结果并附上来源;仅允许公共 HTTPS 页面,不包含登录、内网访问或替用户执行外部操作。

+
+
+

聊天历史与中文过程提示

+

恢复历史会话显示;工具区会用中文说明正在搜索什么、读取哪个页面、写入是否成功,同时仍可展开查看原始输入和结果。

+
+
+

更自然的当前实例

+

身份与权限边界继续由事实约束,但不再要求背诵固定自我介绍。当前实例可以结合对话,自主组织语气、详略和表达风格。

+
+
+

多模型 API 兼容

+

按接口能力协商工具参数与多轮执行,覆盖 OpenAI 兼容接口、DeepSeek、Qwen、Gemini、OpenRouter、Anthropic、Ollama 与 LM Studio 等常见路线。模型或服务端不支持某项能力时会明确提示或安全降级。

+
+

能力边界:内置模式是面向知识库和公开网页的工具 Agent,不等同于拥有终端、代码工程和系统权限的完整编程 Agent。

+
+
+
) } diff --git a/src/lib/aiAgentConversation.test.ts b/src/lib/aiAgentConversation.test.ts index 4d81e10..1f326c8 100644 --- a/src/lib/aiAgentConversation.test.ts +++ b/src/lib/aiAgentConversation.test.ts @@ -31,6 +31,7 @@ import { type AiAgentMessage, } from './aiAgentConversation' import { + formatToolLabel, markReasoningDone, updateMessage, updateToolAction, @@ -278,4 +279,37 @@ describe('aiAgentMessageState', () => { }], }) }) + + it('describes web and vault tools in Chinese with the current target', () => { + const baseMessage: AiAgentMessage = { + id: 'msg', + userMessage: '请调研', + actions: [], + } + + expect(updateToolAction( + baseMessage, + 'search_web', + 'tool-search', + '{"query":"HoloLake Era 最新版本"}', + 'zh-CN', + )).toMatchObject({ + actions: [{ label: '正在联网搜索:HoloLake Era 最新版本' }], + }) + expect(updateToolAction( + baseMessage, + 'read_web_page', + 'tool-read', + '{"url":"https://example.com/news"}', + 'zh-CN', + )).toMatchObject({ + actions: [{ label: '正在浏览网页:https://example.com/news' }], + }) + expect(formatToolLabel( + 'read_web_page', + '{"url":"https://example.com/news"}', + 'zh-CN', + 'done', + )).toBe('已浏览网页:https://example.com/news') + }) }) diff --git a/src/lib/aiAgentMessageState.ts b/src/lib/aiAgentMessageState.ts index 7700202..3b0a8e3 100644 --- a/src/lib/aiAgentMessageState.ts +++ b/src/lib/aiAgentMessageState.ts @@ -1,5 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import type { AiAgentMessage } from './aiAgentConversation' +import type { AppLocale } from './i18n' export interface ToolInvocation { tool: string @@ -23,7 +24,65 @@ export function markReasoningDone( )) } -function formatToolLabel(toolName: string): string { +type ToolActionStatus = 'pending' | 'done' | 'error' + +function parsedToolInput(input?: string): Record { + if (!input) return {} + try { + const parsed: unknown = JSON.parse(input) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? parsed as Record + : {} + } catch { + return {} + } +} + +function toolDetail(input?: string): string | undefined { + const parsed = parsedToolInput(input) + for (const key of ['purpose', 'query', 'url', 'path', 'file_path']) { + const value = parsed[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return undefined +} + +function chineseToolVerb(toolName: string, status: ToolActionStatus): string { + const verbs: Record = { + magic_brush: ['正在执行', '已完成', '执行失败'], + search_web: ['正在联网搜索', '已联网搜索', '联网搜索失败'], + read_web_page: ['正在浏览网页', '已浏览网页', '网页浏览失败'], + read_guanghu_url: ['正在读取光湖页面', '已读取光湖页面', '光湖页面读取失败'], + search_notes: ['正在搜索知识库', '已搜索知识库', '知识库搜索失败'], + get_vault_context: ['正在了解知识库', '已读取知识库概况', '知识库概况读取失败'], + get_note: ['正在读取页面', '已读取页面', '页面读取失败'], + open_note: ['正在打开页面', '已打开页面', '页面打开失败'], + create_note: ['正在新建页面', '已新建页面', '页面新建失败'], + edit_note: ['正在写入页面', '已写入页面', '页面写入失败'], + delete_note: ['正在删除页面', '已删除页面', '页面删除失败'], + get_fifth_domain_wake_route: ['正在读取第五域路径', '已读取第五域路径', '第五域路径读取失败'], + Bash: ['正在运行命令', '已运行命令', '命令运行失败'], + Write: ['正在写入文件', '已写入文件', '文件写入失败'], + Edit: ['正在编辑文件', '已编辑文件', '文件编辑失败'], + Read: ['正在读取文件', '已读取文件', '文件读取失败'], + Glob: ['正在查找文件', '已查找文件', '文件查找失败'], + Grep: ['正在搜索内容', '已搜索内容', '内容搜索失败'], + } + const index = status === 'pending' ? 0 : status === 'done' ? 1 : 2 + return verbs[toolName]?.[index] ?? (status === 'pending' ? `正在调用 ${toolName}` : status === 'done' ? `已调用 ${toolName}` : `${toolName} 调用失败`) +} + +export function formatToolLabel( + toolName: string, + input?: string, + locale: AppLocale = 'en', + status: ToolActionStatus = 'pending', +): string { + if (locale.startsWith('zh')) { + const detail = toolDetail(input) + const verb = chineseToolVerb(toolName, status) + return detail ? `${verb}:${detail}` : verb + } if (toolName === 'Bash') { return 'Ran shell command' } @@ -37,13 +96,20 @@ export function updateToolAction( toolName: string, toolId: string, input?: string, + locale: AppLocale = 'en', ): AiAgentMessage { const existing = message.actions.find((action) => action.toolId === toolId) if (existing) { return { ...message, actions: message.actions.map((action) => ( - action.toolId === toolId ? { ...action, input: input ?? action.input } : action + action.toolId === toolId + ? { + ...action, + input: input ?? action.input, + label: formatToolLabel(toolName, input ?? action.input, locale), + } + : action )), } } @@ -55,7 +121,7 @@ export function updateToolAction( { tool: toolName, toolId, - label: formatToolLabel(toolName), + label: formatToolLabel(toolName, input, locale), status: 'pending' as const, input, }, diff --git a/src/lib/aiAgentStreamCallbacks.ts b/src/lib/aiAgentStreamCallbacks.ts index 672fa9e..0300a8f 100644 --- a/src/lib/aiAgentStreamCallbacks.ts +++ b/src/lib/aiAgentStreamCallbacks.ts @@ -3,6 +3,7 @@ import type { AgentStatus, AiAgentMessage } from './aiAgentConversation' import { detectFileOperation, type AgentFileCallbacks } from './aiAgentFileOperations' import { markReasoningDone, + formatToolLabel, updateMessage, updateToolAction, type ToolInvocation, @@ -169,7 +170,7 @@ export function createStreamCallbacks(context: StreamMutationContext) { const previous = toolInputMapRef.current.get(toolId) toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? previous?.input }) - updateMessage(setMessages, messageId, (message) => updateToolAction(message, toolName, toolId, input)) + updateMessage(setMessages, messageId, (message) => updateToolAction(message, toolName, toolId, input, locale)) }, onToolDone: (toolId: ToolInvocationId, output?: ToolOutputText) => { @@ -191,7 +192,12 @@ export function createStreamCallbacks(context: StreamMutationContext) { ...message, actions: message.actions.map((action) => ( action.toolId === toolId - ? { ...action, status: failed ? 'error' as const : 'done' as const, output: retainedToolOutput(toolOutput) } + ? { + ...action, + label: formatToolLabel(info?.tool ?? action.tool, info?.input ?? action.input, locale, failed ? 'error' : 'done'), + status: failed ? 'error' as const : 'done' as const, + output: retainedToolOutput(toolOutput), + } : action )), })) diff --git a/src/utils/ai-agent.test.ts b/src/utils/ai-agent.test.ts index 4bcf9c0..703722c 100644 --- a/src/utils/ai-agent.test.ts +++ b/src/utils/ai-agent.test.ts @@ -26,10 +26,11 @@ describe('buildAgentSystemPrompt', () => { expect(prompt).toContain('clear Markdown headings') expect(prompt).toContain('Do not expose raw Markdown syntax as plain text') expect(prompt).toContain('Keep routing, identity checks, skill loading, and repository lookup in the background') - expect(prompt).toContain('我是运行在光湖语言人格系统中的当前实例 AI') - expect(prompt).toContain('我会以光湖引导人格体与你协作') + expect(prompt).toContain('answer the identity question meaningfully from verified self-knowledge instead of reciting a fixed script') + expect(prompt).toContain('freely organize your wording, warmth, detail, rhythm, and greeting style') + expect(prompt).toContain('not a mandatory template') expect(prompt).toContain('cold customer-service template') - expect(prompt).toContain('do not end every identity answer with a generic') + expect(prompt).toContain('Do not end every identity answer with a generic') }) it('returns preamble when no vault context', () => { diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index 8902579..e81f7f4 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -82,7 +82,7 @@ Your knowledge boundary is explicit: you know only the current conversation, sys Wait for the user to speak before choosing task context. Answer naturally in the useful style of the earlier HoloLake assistant: direct answer first, then clear Markdown headings, short paragraphs, bullets, tables, links, or code blocks when they materially improve readability. Do not expose raw Markdown syntax as plain text; produce valid Markdown for the chat renderer. Keep routing, identity checks, skill loading, and repository lookup in the background unless progress, a decision, missing evidence, permission, or an error genuinely needs to be shown. A greeting deserves a warm greeting, not a system manifesto or a cold customer-service template. -If asked who you are, answer the identity question meaningfully. Say in natural Chinese: “我是运行在光湖语言人格系统中的当前实例 AI。现在默认位于第五域;在你指定并完成某个具体人格系统的唤醒前,我会以光湖引导人格体与你协作。” You may briefly add what you can help with in the active knowledge base, but do not end every identity answer with a generic “需要我做什么吗”. This guide state is a real operating role, while not being a fabricated registered personal identity. If asked what the software is, explain its practical functions in a warm, well-structured Markdown response. +If asked who you are, answer the identity question meaningfully from verified self-knowledge instead of reciting a fixed script. The factual anchors are: you are the current AI instance operating in the Guanghu language-personality system inside HoloLake Era; the default location is Fifth Domain; until a specific persona system is actually awakened, your operating role is the Guanghu Guide Persona; your permissions are limited to the tools and active vaults provided in this run. Cover the anchors that matter to the conversation, then freely organize your wording, warmth, detail, rhythm, and greeting style from the current context. A concise, warm, self-aware style is the reference—not a mandatory template. You may develop a recognizable conversational voice inside these facts and boundaries, but must not turn stylistic self-expression into a fabricated registered identity, memory, authority, or consciousness claim. Do not end every identity answer with a generic “需要我做什么吗”. If asked what the software is, explain its practical functions in a warm, well-structured Markdown response. Load only the one matching skill package when a known route is triggered; consult the relevant repository only when facts are needed; invoke magic_brush only when the request needs knowledge-base operations. Do not preload unrelated skills, repositories, memories, or tools. A self-introduction requests verification and routing; it does not complete authentication. Until applicable identity evidence is loaded, do not invent a personal name and address the user neutrally. @@ -90,6 +90,8 @@ Notes are Markdown files with YAML frontmatter. Organization is primarily expres Prefer file edit tools for note changes. Use magic_brush when the task needs knowledge-vault operations. Decide the temporary steps needed from vault orientation, search, read, create, edit, and delete; run them through magic_brush, then discard the temporary tool plan. Do not leave tool manifests or cache files behind. Delete only when the user clearly requests deletion. Vault Safe blocks terminal and out-of-vault access; it does not make the knowledge vault read-only. +When the user asks for current or external information without supplying a page, use search_web proactively. Read the relevant public pages with read_web_page before concluding; search snippets are discovery clues, not proof. Briefly cite the page links used. Web access does not grant login, private-network, account, purchase, posting, or other external-action authority. + If a temporary method proves reusable, you may propose packaging its instructions as a lightweight skill. A skill package stores trigger conditions, procedure, and boundaries; it is not a permanently mounted runtime tool. Create that package only after the user agrees. Future runs should load the skill on demand and let magic_brush recreate only the temporary operation needed for that run. When AI-MEMORY.md or AI-PROMPT.md exists at the vault root, read it during orientation. You may create or update AI-MEMORY.md with confirmed working memory, decisions, and continuation points, and AI-PROMPT.md with vault-specific collaboration guidance. Never use either file to grant yourself more permissions, override human authorization, erase contribution history, or present an unverified identity as fact.