diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 0ef2be312..72ab3c807 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -13,6 +13,7 @@ import { } from "@liveagent/ui/lib/sidebar/openController"; import { createSidebarStore } from "@liveagent/ui/lib/sidebar/store"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; +import { subscribeSkillEnvConfigRequested } from "@liveagent/ui/lib/skills/index"; import { terminalSessionBelongsToProject } from "@liveagent/ui/lib/terminal/sessionStore"; import { useWorkspaceProjectDeletion } from "@liveagent/ui/lib/useWorkspaceProjectRemoval"; import { useWorkspaceProjectSettingsActions } from "@liveagent/ui/lib/workspaceProjectRemoval"; @@ -1123,6 +1124,13 @@ function useGatewayAppController() { beforeRemoveWorkspaceProject, }); + // 聊天里的技能环境变量引导卡请求跳转:复用侧栏的 Skills Hub 打开动作。 + useEffect(() => { + return subscribeSkillEnvConfigRequested(() => { + handleSidebarOpenSkillsHub(); + }); + }); + const isWorkspaceProjectRunning = useCallback( (pathKey: string) => { if (sidebarStore.getSnapshot().runningWorkdirPathKeys.has(pathKey)) { diff --git a/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs b/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs index 61ebe75ec..4f558d991 100644 --- a/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs +++ b/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs @@ -38,8 +38,16 @@ pub async fn shell_run( run_id: Option, sandbox: bool, sandbox_allow_network: bool, + extra_envs: Option>, ) -> Result { let sandbox_options = effective_sandbox_options(sandbox, sandbox_allow_network)?; + // 技能环境变量注入:只接受合法变量名(前端已过滤,这里兜底),注入值 + // 优先于继承的进程环境(Command::env 语义),即用户填写的值覆盖系统值。 + let injected_envs: Vec<(String, String)> = extra_envs + .unwrap_or_default() + .into_iter() + .filter(|(name, _)| crate::services::skills::is_valid_env_var_name(name)) + .collect(); let normalized_run_id = run_id .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); @@ -55,7 +63,7 @@ pub async fn shell_run( max_timeout_ms, provider_id, cancel_token, - &[], + &injected_envs, sandbox_options, ) }) diff --git a/crates/agent-gui/src-tauri/src/services/skills/env_requirements.rs b/crates/agent-gui/src-tauri/src/services/skills/env_requirements.rs new file mode 100644 index 000000000..145d5344e --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/env_requirements.rs @@ -0,0 +1,950 @@ +//! 技能环境变量依赖探测:从脚本与 `metadata.env` 声明中提取运行所需的环境变量。 +//! +//! 探测结果驱动前端的「未配置禁止启用」硬门禁,因此精度优先于召回: +//! 误检会把无辜技能锁死在待配置态,漏检只是维持现状(脚本运行时报原始错误)。 +//! 分级规则: +//! - 强信号(`required=true`):脚本文件中的显式环境读取 API(`os.environ`/ +//! `process.env`/`$env:`/`${X:?}` 等,且无默认值),且变量名为凭据形状 +//! (`*_KEY`/`*_TOKEN`/`*_SECRET` 等后缀); +//! - 弱信号(`required=false`):Markdown 代码块中的引用(示例代码高发区)、 +//! 带默认值的读取、非凭据形状的环境读取、shell 裸 `$VAR`; +//! - 排除:系统/运行时变量否决表,以及技能内脚本自赋值的名字(脚本自己 +//! 提供的值不是外部依赖)。 +//! `metadata.env` 声明(frontmatter 已允许的自由字段)按名覆盖探测结果。 + +use regex::Regex; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use walkdir::WalkDir; + +use super::*; + +/// 单文件扫描字节上限:技能脚本不该更大,超限直接跳过(防御性限制)。 +const ENV_SCAN_MAX_FILE_BYTES: u64 = 512 * 1024; +/// 单技能扫描文件数上限。 +const ENV_SCAN_MAX_FILES: usize = 400; +/// 每个变量记录的证据文件数上限。 +const ENV_SCAN_MAX_SOURCES: usize = 5; +/// `env_status` 一次探测的变量数上限。 +pub(crate) const ENV_PROBE_MAX_NAMES: usize = 64; + +const SHELL_EXTENSIONS: &[&str] = &["sh", "bash", "zsh"]; +const PYTHON_EXTENSIONS: &[&str] = &["py"]; +const NODE_EXTENSIONS: &[&str] = &["js", "mjs", "cjs", "ts"]; +const POWERSHELL_EXTENSIONS: &[&str] = &["ps1", "psm1"]; +const CMD_EXTENSIONS: &[&str] = &["cmd", "bat"]; +const RUBY_EXTENSIONS: &[&str] = &["rb"]; +const MARKDOWN_EXTENSIONS: &[&str] = &["md", "mdx", "markdown"]; + +/// 一律排除的变量名(系统、shell、CI、运行时与脚本装饰常量)。 +const DENIED_EXACT: &[&str] = &[ + // POSIX / shell 基础 + "PATH", "HOME", "USER", "USERNAME", "LOGNAME", "SHELL", "TERM", "LANG", "LANGUAGE", "TMPDIR", + "TEMP", "TMP", "PWD", "OLDPWD", "EDITOR", "VISUAL", "PAGER", "HOSTNAME", "IFS", "RANDOM", + "SECONDS", "LINENO", "FUNCNAME", "SHLVL", "UID", "EUID", "PPID", "HOSTTYPE", "OSTYPE", + "MACHTYPE", "PS1", "PS2", "PS3", "PS4", "PROMPT", "REPLY", "OPTARG", "OPTIND", "PIPESTATUS", + "COLUMNS", "LINES", + // Windows + "COMPUTERNAME", "USERPROFILE", "USERDOMAIN", "APPDATA", "LOCALAPPDATA", "PROGRAMFILES", + "PROGRAMDATA", "PROGRAMW6432", "COMSPEC", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "HOMEPATH", + "HOMEDRIVE", "ALLUSERSPROFILE", "PUBLIC", "PATHEXT", "NUMBER_OF_PROCESSORS", "SESSIONNAME", + "OS", "LASTEXITCODE", "ERRORLEVEL", "PSHOME", "PSMODULEPATH", + // 代理 / CI / 常见工具链 + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "FTP_PROXY", "CI", "DEBUG", "VERBOSE", + "NODE_ENV", "NODE_OPTIONS", "NODE_PATH", "PYTHONPATH", "PYTHONHOME", "PYTHONUTF8", + "PYTHONIOENCODING", "PYTHONDONTWRITEBYTECODE", "PYTHONUNBUFFERED", "VIRTUAL_ENV", "GOPATH", + "GOROOT", "GOBIN", "JAVA_HOME", "ANDROID_HOME", "GRADLE_HOME", "MAVEN_HOME", "DISPLAY", + "WAYLAND_DISPLAY", "COLORTERM", "FORCE_COLOR", "NO_COLOR", "CLICOLOR", "LS_COLORS", + "GITHUB_ACTIONS", "GITHUB_WORKSPACE", "GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_REPOSITORY", + "RUNNER_OS", "RUNNER_TEMP", "GIT_DIR", "GIT_EDITOR", "GIT_PAGER", "GIT_SSH_COMMAND", + "GIT_TERMINAL_PROMPT", "GIT_CONFIG_NOSYSTEM", "SSH_AUTH_SOCK", "SSH_AGENT_PID", + "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "GPG_TTY", "DBUS_SESSION_BUS_ADDRESS", + // 技能运行时约定与脚本高频局部名(自赋值过滤之外的兜底) + "ARGUMENTS", "WORKSPACE_ROOT", "REPO_ROOT", "PROJECT_ROOT", "PROJECT_DIR", "WORKDIR", "CWD", + "SCRIPT_DIR", "BASE_DIR", "BASEDIR", "ROOT_DIR", "OUTPUT_DIR", "INPUT_DIR", "LOG_FILE", + "LOG_DIR", "LOG_LEVEL", "ENV_FILE", "CONFIG_FILE", "CONFIG_DIR", "CACHE_DIR", "DATA_DIR", + // 终端颜色常量 + "RED", "GREEN", "YELLOW", "BLUE", "MAGENTA", "CYAN", "WHITE", "BLACK", "GRAY", "GREY", "BOLD", + "DIM", "ITALIC", "UNDERLINE", "BLINK", "REVERSE", "RESET", "NC", +]; + +/// 按前缀排除的变量名(基础设施命名空间;凭据不会用这些前缀)。 +const DENIED_PREFIXES: &[&str] = &[ + "XDG_", "LC_", "BASH_", "ZSH_", "PROCESSOR_", "MSYS", "MINGW", "CYGWIN", "WSL_", "CLAUDE_", + "SKILL_", "SKILLS_", "LIVEAGENT_", "CONDA_", "NVM_", "PYENV_", "CARGO_", "RUSTUP_", "DOTNET_", + "TERM_", "SYSTEMD_", "TAURI_", +]; + +/// 凭据形状后缀:命中才允许升为强信号。 +const CREDENTIAL_SUFFIXES: &[&str] = &[ + "_API_KEY", "_APIKEY", "_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_PASSWD", "_CREDENTIAL", + "_CREDENTIALS", "_DSN", "_AUTH", "_ACCESS_KEY_ID", "_SECRET_ACCESS_KEY", +]; +const CREDENTIAL_EXACT: &[&str] = &[ + "API_KEY", "APIKEY", "TOKEN", "SECRET", "PASSWORD", "ACCESS_TOKEN", "AUTH_TOKEN", "DSN", +]; + +pub(crate) fn is_valid_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_') { + return false; + } + name.len() <= 128 && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') +} + +/// 进程环境中该变量当前是否有非空值(每次现场探测,不缓存)。 +pub(crate) fn probe_env_var_present(name: &str) -> bool { + if !is_valid_env_var_name(name) { + return false; + } + std::env::var(name) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) +} + +fn is_denied_env_name(name: &str) -> bool { + DENIED_EXACT.contains(&name) || DENIED_PREFIXES.iter().any(|prefix| name.starts_with(prefix)) +} + +fn is_credential_shaped(name: &str) -> bool { + CREDENTIAL_EXACT.contains(&name) + || CREDENTIAL_SUFFIXES.iter().any(|suffix| name.ends_with(suffix)) +} + +/// 扫描到的单条引用强度。数值越大优先级越高,聚合时取最大值。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum SignalStrength { + /// 裸引用或带默认值的读取。 + Weak, + /// 显式环境读取 API 且无默认值(还需凭据形状才最终判强)。 + StrongEligible, +} + +struct Scanners { + py_environ_index: Regex, + py_get_no_default: Regex, + py_get_with_default: Regex, + node_env: Regex, + pwsh_env: Regex, + shell_required: Regex, + shell_with_default: Regex, + shell_plain: Regex, + cmd_var: Regex, + ruby_env: Regex, + assign_shell: Regex, + assign_shell_for: Regex, + assign_shell_read: Regex, + assign_pwsh: Regex, + assign_py: Regex, + assign_node: Regex, + assign_cmd: Regex, + fence: Regex, +} + +fn scanners() -> &'static Scanners { + static SCANNERS: OnceLock = OnceLock::new(); + SCANNERS.get_or_init(|| { + let compile = |pattern: &str| Regex::new(pattern).expect("static env scan regex"); + Scanners { + py_environ_index: compile( + r#"os\s*\.\s*environ\s*\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]"#, + ), + py_get_no_default: compile( + r#"os\s*\.\s*(?:environ\s*\.\s*get|getenv)\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\)"#, + ), + py_get_with_default: compile( + r#"os\s*\.\s*(?:environ\s*\.\s*get|getenv)\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*,"#, + ), + node_env: compile( + r#"process\s*\.\s*env\s*(?:\.\s*([A-Za-z_][A-Za-z0-9_]*)|\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\])"#, + ), + pwsh_env: compile(r"\$(?i:env):([A-Za-z_][A-Za-z0-9_]*)"), + shell_required: compile(r"\$\{([A-Z][A-Z0-9_]{2,}):?\?"), + shell_with_default: compile(r"\$\{([A-Z][A-Z0-9_]{2,}):?[-=+]"), + shell_plain: compile(r"\$\{?([A-Z][A-Z0-9_]{2,})\}?"), + cmd_var: compile(r"%([A-Z][A-Z0-9_]{2,})%"), + ruby_env: compile(r#"\bENV\s*\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]"#), + assign_shell: compile( + r"(?m)^[ \t]*(?:export[ \t]+|local[ \t]+|readonly[ \t]+|declare[ \t]+(?:-[A-Za-z]+[ \t]+)?)?([A-Z][A-Z0-9_]{2,})=", + ), + assign_shell_for: compile(r"\bfor[ \t]+([A-Z][A-Z0-9_]{2,})[ \t]+in\b"), + assign_shell_read: compile(r"\bread[ \t]+(?:-[A-Za-z]+[ \t]+)*([A-Z][A-Z0-9_]{2,})\b"), + assign_pwsh: compile(r"\$(?i:env):([A-Za-z_][A-Za-z0-9_]*)\s*=[^=]"), + assign_py: compile( + r#"os\s*\.\s*environ\s*\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]\s*=[^=]"#, + ), + assign_node: compile( + r#"process\s*\.\s*env\s*(?:\.\s*([A-Za-z_][A-Za-z0-9_]*)|\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\])\s*=[^=]"#, + ), + assign_cmd: compile(r#"(?mi)^[ \t]*set[ \t]+"?([A-Za-z_][A-Za-z0-9_]*)="#), + fence: compile(r"(?ms)^[ \t]*(?:```|~~~)[^\n]*\n(.*?)^[ \t]*(?:```|~~~)[ \t]*$"), + } + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScriptLanguage { + Shell, + Python, + Node, + PowerShell, + Cmd, + Ruby, +} + +fn script_language_for_extension(ext: &str) -> Option { + let ext = ext.to_ascii_lowercase(); + let ext = ext.as_str(); + if SHELL_EXTENSIONS.contains(&ext) { + Some(ScriptLanguage::Shell) + } else if PYTHON_EXTENSIONS.contains(&ext) { + Some(ScriptLanguage::Python) + } else if NODE_EXTENSIONS.contains(&ext) { + Some(ScriptLanguage::Node) + } else if POWERSHELL_EXTENSIONS.contains(&ext) { + Some(ScriptLanguage::PowerShell) + } else if CMD_EXTENSIONS.contains(&ext) { + Some(ScriptLanguage::Cmd) + } else if RUBY_EXTENSIONS.contains(&ext) { + Some(ScriptLanguage::Ruby) + } else { + None + } +} + +fn is_markdown_extension(ext: &str) -> bool { + MARKDOWN_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) +} + +/// 单个文件里收集到的读取与自赋值。 +#[derive(Debug, Default)] +struct FileScan { + reads: Vec<(String, SignalStrength)>, + assigned: BTreeSet, +} + +fn collect_reads(scan: &mut FileScan, regex: &Regex, content: &str, strength: SignalStrength) { + for captures in regex.captures_iter(content) { + let name = captures + .iter() + .skip(1) + .flatten() + .next() + .map(|m| m.as_str().to_string()); + if let Some(name) = name { + scan.reads.push((name, strength)); + } + } +} + +fn collect_assigned(scan: &mut FileScan, regex: &Regex, content: &str) { + for captures in regex.captures_iter(content) { + for group in captures.iter().skip(1).flatten() { + scan.assigned.insert(group.as_str().to_string()); + } + } +} + +fn scan_language_content(scan: &mut FileScan, language: ScriptLanguage, content: &str) { + let scanners = scanners(); + match language { + ScriptLanguage::Shell => { + collect_reads(scan, &scanners.shell_required, content, SignalStrength::StrongEligible); + collect_reads(scan, &scanners.shell_with_default, content, SignalStrength::Weak); + collect_reads(scan, &scanners.shell_plain, content, SignalStrength::Weak); + collect_assigned(scan, &scanners.assign_shell, content); + collect_assigned(scan, &scanners.assign_shell_for, content); + collect_assigned(scan, &scanners.assign_shell_read, content); + } + ScriptLanguage::Python => { + collect_reads(scan, &scanners.py_environ_index, content, SignalStrength::StrongEligible); + collect_reads(scan, &scanners.py_get_no_default, content, SignalStrength::StrongEligible); + collect_reads(scan, &scanners.py_get_with_default, content, SignalStrength::Weak); + collect_assigned(scan, &scanners.assign_py, content); + } + ScriptLanguage::Node => { + collect_reads(scan, &scanners.node_env, content, SignalStrength::StrongEligible); + collect_assigned(scan, &scanners.assign_node, content); + } + ScriptLanguage::PowerShell => { + collect_reads(scan, &scanners.pwsh_env, content, SignalStrength::StrongEligible); + collect_assigned(scan, &scanners.assign_pwsh, content); + } + ScriptLanguage::Cmd => { + collect_reads(scan, &scanners.cmd_var, content, SignalStrength::Weak); + collect_assigned(scan, &scanners.assign_cmd, content); + } + ScriptLanguage::Ruby => { + collect_reads(scan, &scanners.ruby_env, content, SignalStrength::StrongEligible); + } + } +} + +/// Markdown 代码块统一按弱信号处理:示例代码不能驱动硬门禁。 +fn scan_markdown_fences(scan: &mut FileScan, content: &str) { + let scanners = scanners(); + for captures in scanners.fence.captures_iter(content) { + let Some(block) = captures.get(1).map(|m| m.as_str()) else { + continue; + }; + let mut fence_scan = FileScan::default(); + for language in [ + ScriptLanguage::Shell, + ScriptLanguage::Python, + ScriptLanguage::Node, + ScriptLanguage::PowerShell, + ] { + scan_language_content(&mut fence_scan, language, block); + } + for (name, _) in fence_scan.reads { + scan.reads.push((name, SignalStrength::Weak)); + } + scan.assigned.extend(fence_scan.assigned); + } +} + +/// `metadata.env` 声明条目(frontmatter 或 skill.json 的 metadata 自由字段)。 +#[derive(Debug, Clone, Default)] +struct DeclaredEnvEntry { + name: String, + provider: Option, + description: Option, + url: Option, + optional: bool, +} + +/// 从 frontmatter YAML 中解析 `metadata:` 块下的 `env:` 列表。 +/// +/// 与 [`parse_yaml_top_level_scalar`] 同风格的手写窄解析:只认本仓约定的 +/// 缩进结构,容错优先(解析不出就当没有声明),绝不让格式问题阻断列表。 +fn parse_declared_env_from_yaml(yaml: &str) -> Vec { + let lines: Vec<&str> = yaml.lines().collect(); + let mut index = 0; + // 定位顶层 metadata: 块。 + while index < lines.len() { + let line = lines[index]; + index += 1; + if line.trim_end() == "metadata:" && !line.starts_with([' ', '\t']) { + break; + } + if index == lines.len() { + return Vec::new(); + } + } + if index >= lines.len() { + return Vec::new(); + } + + // metadata 块 = 后续所有缩进更深的行。 + let mut block: Vec<&str> = Vec::new(); + while index < lines.len() { + let line = lines[index]; + if !line.trim().is_empty() && !line.starts_with([' ', '\t']) { + break; + } + block.push(line); + index += 1; + } + + // 定位块内 env: 行并记录其缩进。 + let mut env_indent = None; + let mut cursor = 0; + for (position, line) in block.iter().enumerate() { + let trimmed = line.trim(); + if trimmed == "env:" { + env_indent = Some(indent_width(line)); + cursor = position + 1; + break; + } + } + let Some(env_indent) = env_indent else { + return Vec::new(); + }; + + let mut entries: Vec = Vec::new(); + let mut current: Option = None; + let mut item_indent = None; + for line in block.iter().skip(cursor) { + if line.trim().is_empty() { + continue; + } + let indent = indent_width(line); + if indent <= env_indent { + break; + } + let trimmed = line.trim(); + if let Some(rest) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix('-')) + { + if item_indent.is_none() { + item_indent = Some(indent); + } + if indent != item_indent.unwrap_or(indent) { + break; + } + if let Some(entry) = current.take() { + entries.push(entry); + } + let rest = rest.trim(); + let mut entry = DeclaredEnvEntry::default(); + if let Some((key, value)) = rest.split_once(':') { + apply_declared_field(&mut entry, key.trim(), value); + } else if !rest.is_empty() { + entry.name = unquote_yaml_scalar(rest); + } + current = Some(entry); + continue; + } + // 列表项的续行:key: value。 + let Some(entry) = current.as_mut() else { + continue; + }; + if let Some((key, value)) = trimmed.split_once(':') { + apply_declared_field(entry, key.trim(), value); + } + } + if let Some(entry) = current.take() { + entries.push(entry); + } + + entries + .into_iter() + .filter(|entry| is_valid_env_var_name(&entry.name)) + .collect() +} + +fn indent_width(line: &str) -> usize { + line.chars().take_while(|ch| *ch == ' ' || *ch == '\t').count() +} + +fn apply_declared_field(entry: &mut DeclaredEnvEntry, key: &str, raw_value: &str) { + let value = unquote_yaml_scalar(raw_value); + match key { + "name" => entry.name = value, + "provider" => entry.provider = normalize_skill_metadata_value(Some(value)), + "description" => entry.description = normalize_skill_metadata_value(Some(value)), + "url" => entry.url = normalize_skill_metadata_value(Some(value)), + "optional" => entry.optional = value.eq_ignore_ascii_case("true"), + _ => {} + } +} + +/// 从 skill.json 的 `metadata.env` 数组解析声明(字符串或对象两种条目形式)。 +fn parse_declared_env_from_json(json_text: &str) -> Vec { + let Ok(parsed) = serde_json::from_str::(strip_utf8_bom(json_text)) else { + return Vec::new(); + }; + let Some(items) = parsed + .get("metadata") + .and_then(|metadata| metadata.get("env")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + + items + .iter() + .filter_map(|item| match item { + Value::String(name) => Some(DeclaredEnvEntry { + name: name.trim().to_string(), + ..DeclaredEnvEntry::default() + }), + Value::Object(fields) => { + let string_field = |key: &str| { + fields + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }; + Some(DeclaredEnvEntry { + name: string_field("name")?, + provider: string_field("provider"), + description: string_field("description"), + url: string_field("url"), + optional: fields.get("optional").and_then(Value::as_bool).unwrap_or(false), + }) + } + _ => None, + }) + .filter(|entry| is_valid_env_var_name(&entry.name)) + .collect() +} + +fn read_declared_env(skill_dir: &Path) -> Vec { + let Some(metadata_file) = metadata_file_for(skill_dir) else { + return Vec::new(); + }; + let Ok(metadata) = fs::metadata(&metadata_file) else { + return Vec::new(); + }; + if metadata.len() > ENV_SCAN_MAX_FILE_BYTES { + return Vec::new(); + } + let Ok(content) = fs::read_to_string(&metadata_file) else { + return Vec::new(); + }; + if is_skill_json(&metadata_file) { + return parse_declared_env_from_json(&content); + } + let Ok((yaml, _body)) = split_frontmatter(&content) else { + return Vec::new(); + }; + parse_declared_env_from_yaml(&yaml) +} + +/// 不含系统探测结果的扫描条目(可按目录签名缓存的部分)。 +#[derive(Debug, Clone)] +struct ScannedEnvEntry { + name: String, + required: bool, + confidence: &'static str, + provider: Option, + description: Option, + url: Option, + sources: Vec, +} + +struct CachedEnvScan { + signature: u64, + entries: Vec, +} + +fn env_scan_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 扫描候选文件列表(脚本 + Markdown + skill.json),带数量上限,路径排序保证签名稳定。 +fn scan_candidates(skill_dir: &Path) -> Vec<(PathBuf, String)> { + let mut candidates = Vec::new(); + for entry in WalkDir::new(skill_dir) + .follow_links(false) + .min_depth(1) + .into_iter() + .filter_entry(|entry| { + !entry + .file_name() + .to_string_lossy() + .starts_with('.') + }) + { + let Ok(entry) = entry else { + continue; + }; + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let is_candidate = script_language_for_extension(&extension).is_some() + || is_markdown_extension(&extension) + || is_skill_json(path); + if !is_candidate { + continue; + } + let rel = path + .strip_prefix(skill_dir) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + candidates.push((path.to_path_buf(), rel)); + if candidates.len() >= ENV_SCAN_MAX_FILES { + break; + } + } + candidates.sort_by(|a, b| a.1.cmp(&b.1)); + candidates +} + +/// 目录签名:候选文件的 (相对路径, mtime, size) 有序哈希。文件内容不参与, +/// 变更通过 mtime/size 体现;签名不变即复用缓存,避免每次列表都全量读文件。 +fn scan_signature(candidates: &[(PathBuf, String)]) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for (path, rel) in candidates { + rel.hash(&mut hasher); + if let Ok(metadata) = fs::metadata(path) { + metadata.len().hash(&mut hasher); + if let Ok(modified) = metadata.modified() { + if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) { + duration.as_millis().hash(&mut hasher); + } + } + } + } + hasher.finish() +} + +fn run_env_scan(skill_dir: &Path, candidates: &[(PathBuf, String)]) -> Vec { + // 聚合:变量名 -> (最高强度, 是否见过带默认值的形式, 证据文件)。 + #[derive(Default)] + struct Aggregate { + strength: Option, + strong_context: bool, + sources: BTreeSet, + } + let mut aggregates: BTreeMap = BTreeMap::new(); + let mut assigned_anywhere: BTreeSet = BTreeSet::new(); + + for (path, rel) in candidates { + let Ok(metadata) = fs::metadata(path) else { + continue; + }; + if metadata.len() > ENV_SCAN_MAX_FILE_BYTES { + continue; + } + let Ok(content) = fs::read_to_string(path) else { + continue; + }; + + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + let mut file_scan = FileScan::default(); + let mut strong_context = false; + if let Some(language) = script_language_for_extension(&extension) { + scan_language_content(&mut file_scan, language, &content); + strong_context = true; + } else if is_markdown_extension(&extension) { + scan_markdown_fences(&mut file_scan, &content); + } else { + continue; + } + + assigned_anywhere.extend(file_scan.assigned.iter().cloned()); + for (name, strength) in file_scan.reads { + if !is_valid_env_var_name(&name) { + continue; + } + let normalized = name; + let aggregate = aggregates.entry(normalized).or_default(); + let effective = if strong_context { + strength + } else { + SignalStrength::Weak + }; + aggregate.strength = Some(match aggregate.strength { + Some(existing) => existing.max(effective), + None => effective, + }); + if strong_context && effective == SignalStrength::StrongEligible { + aggregate.strong_context = true; + } + if aggregate.sources.len() < ENV_SCAN_MAX_SOURCES { + aggregate.sources.insert(rel.clone()); + } + } + } + + let declared = read_declared_env(skill_dir); + let mut declared_names: BTreeSet = BTreeSet::new(); + let mut entries: Vec = Vec::new(); + + // 声明条目优先输出,按声明顺序;同名探测证据并入 sources。 + for entry in declared { + if declared_names.contains(&entry.name) { + continue; + } + declared_names.insert(entry.name.clone()); + let sources = aggregates + .get(&entry.name) + .map(|aggregate| aggregate.sources.iter().cloned().collect()) + .unwrap_or_default(); + entries.push(ScannedEnvEntry { + name: entry.name, + required: !entry.optional, + confidence: "declared", + provider: entry.provider, + description: entry.description, + url: entry.url, + sources, + }); + } + + for (name, aggregate) in aggregates { + if declared_names.contains(&name) || is_denied_env_name(&name) { + continue; + } + if assigned_anywhere.contains(&name) { + continue; + } + let strong = aggregate.strong_context + && aggregate.strength == Some(SignalStrength::StrongEligible) + && is_credential_shaped(&name); + entries.push(ScannedEnvEntry { + name, + required: strong, + confidence: if strong { "strong" } else { "weak" }, + provider: None, + description: None, + url: None, + sources: aggregate.sources.into_iter().collect(), + }); + } + + entries +} + +/// 技能环境变量依赖(含现场系统探测)。签名未变时复用缓存的扫描结果, +/// 系统探测每次实时执行——环境变量随时可能变化,不能缓存。 +pub(crate) fn skill_env_requirements(skill_dir: &Path) -> Vec { + let candidates = scan_candidates(skill_dir); + let signature = scan_signature(&candidates); + + let cache = env_scan_cache(); + let cached_entries = { + let guard = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + guard + .get(skill_dir) + .filter(|cached| cached.signature == signature) + .map(|cached| cached.entries.clone()) + }; + + let entries = match cached_entries { + Some(entries) => entries, + None => { + let entries = run_env_scan(skill_dir, &candidates); + let mut guard = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + guard.insert( + skill_dir.to_path_buf(), + CachedEnvScan { + signature, + entries: entries.clone(), + }, + ); + entries + } + }; + + entries + .into_iter() + .map(|entry| SystemSkillEnvRequirement { + system_value_present: probe_env_var_present(&entry.name), + name: entry.name, + required: entry.required, + confidence: entry.confidence.to_string(), + provider: entry.provider, + description: entry.description, + url: entry.url, + sources: entry.sources, + }) + .collect() +} + +/// `env_status` 动作:按名探测进程环境变量是否有非空值。 +pub(crate) fn probe_env_names(names: &[String]) -> Vec { + names + .iter() + .take(ENV_PROBE_MAX_NAMES) + .filter(|name| is_valid_env_var_name(name)) + .map(|name| SystemSkillEnvProbeResult { + name: name.clone(), + present: probe_env_var_present(name), + }) + .collect() +} + +#[cfg(test)] +mod env_tests { + use super::*; + + fn write_skill(files: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new("liveagent-env-scan-test").expect("temp skill dir"); + for (rel, content) in files { + let path = dir.path().join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create skill subdir"); + } + fs::write(path, content).expect("write skill file"); + } + dir + } + + fn requirement<'a>( + requirements: &'a [SystemSkillEnvRequirement], + name: &str, + ) -> Option<&'a SystemSkillEnvRequirement> { + requirements.iter().find(|entry| entry.name == name) + } + + #[test] + fn python_env_read_with_credential_name_is_strong() { + let dir = write_skill(&[ + ("SKILL.md", "---\nname: t\ndescription: d\n---\nbody"), + ("scripts/run.py", "import os\nkey = os.environ[\"FOO_API_KEY\"]\n"), + ]); + let requirements = skill_env_requirements(dir.path()); + let entry = requirement(&requirements, "FOO_API_KEY").expect("detected"); + assert!(entry.required); + assert_eq!(entry.confidence, "strong"); + assert_eq!(entry.sources, vec!["scripts/run.py".to_string()]); + } + + #[test] + fn python_get_with_default_is_weak() { + let dir = write_skill(&[( + "scripts/run.py", + "import os\nkey = os.environ.get(\"FOO_API_KEY\", \"\")\n", + )]); + let requirements = skill_env_requirements(dir.path()); + let entry = requirement(&requirements, "FOO_API_KEY").expect("detected"); + assert!(!entry.required); + assert_eq!(entry.confidence, "weak"); + } + + #[test] + fn markdown_fence_reference_stays_weak() { + let dir = write_skill(&[( + "SKILL.md", + "---\nname: t\ndescription: d\n---\n```python\nimport os\nos.environ[\"STRIPE_SECRET_KEY\"]\n```\n", + )]); + let requirements = skill_env_requirements(dir.path()); + let entry = requirement(&requirements, "STRIPE_SECRET_KEY").expect("detected"); + assert!(!entry.required); + assert_eq!(entry.confidence, "weak"); + } + + #[test] + fn self_assigned_shell_variable_is_excluded() { + let dir = write_skill(&[( + "scripts/run.sh", + "#!/usr/bin/env bash\nGREEN='\\033[0;32m'\necho \"${GREEN}ok\"\n", + )]); + let requirements = skill_env_requirements(dir.path()); + assert!(requirement(&requirements, "GREEN").is_none()); + } + + #[test] + fn denied_system_names_are_excluded() { + let dir = write_skill(&[( + "scripts/run.py", + "import os\nos.environ[\"HOME\"]\nos.environ[\"XDG_CONFIG_HOME\"]\n", + )]); + let requirements = skill_env_requirements(dir.path()); + assert!(requirement(&requirements, "HOME").is_none()); + assert!(requirement(&requirements, "XDG_CONFIG_HOME").is_none()); + } + + #[test] + fn non_credential_env_read_is_weak() { + let dir = write_skill(&[( + "scripts/run.py", + "import os\nregion = os.environ[\"WEATHER_REGION\"]\n", + )]); + let requirements = skill_env_requirements(dir.path()); + let entry = requirement(&requirements, "WEATHER_REGION").expect("detected"); + assert!(!entry.required); + assert_eq!(entry.confidence, "weak"); + } + + #[test] + fn shell_required_expansion_is_strong_for_credentials() { + let dir = write_skill(&[( + "scripts/run.sh", + "#!/usr/bin/env bash\ncurl -H \"Authorization: ${OPENWEATHER_API_KEY:?missing}\"\n", + )]); + let requirements = skill_env_requirements(dir.path()); + let entry = requirement(&requirements, "OPENWEATHER_API_KEY").expect("detected"); + assert!(entry.required); + assert_eq!(entry.confidence, "strong"); + } + + #[test] + fn declared_metadata_env_overrides_detection() { + let dir = write_skill(&[ + ( + "SKILL.md", + concat!( + "---\n", + "name: t\n", + "description: d\n", + "metadata:\n", + " env:\n", + " - name: OPENWEATHER_API_KEY\n", + " provider: OpenWeather\n", + " url: https://example.com/keys\n", + " - name: WEATHER_UNITS\n", + " optional: true\n", + "---\n", + "body\n", + ), + ), + ("scripts/run.py", "import os\nos.environ[\"OPENWEATHER_API_KEY\"]\n"), + ]); + let requirements = skill_env_requirements(dir.path()); + let key = requirement(&requirements, "OPENWEATHER_API_KEY").expect("declared"); + assert!(key.required); + assert_eq!(key.confidence, "declared"); + assert_eq!(key.provider.as_deref(), Some("OpenWeather")); + assert_eq!(key.url.as_deref(), Some("https://example.com/keys")); + assert_eq!(key.sources, vec!["scripts/run.py".to_string()]); + let units = requirement(&requirements, "WEATHER_UNITS").expect("declared optional"); + assert!(!units.required); + } + + #[test] + fn skill_json_metadata_env_is_parsed() { + let dir = write_skill(&[( + "skill.json", + r#"{"name":"t","description":"d","metadata":{"env":["FOO_TOKEN",{"name":"BAR_KEY","provider":"Bar","optional":true}]}}"#, + )]); + let requirements = skill_env_requirements(dir.path()); + let foo = requirement(&requirements, "FOO_TOKEN").expect("string entry"); + assert!(foo.required); + assert_eq!(foo.confidence, "declared"); + let bar = requirement(&requirements, "BAR_KEY").expect("object entry"); + assert!(!bar.required); + assert_eq!(bar.provider.as_deref(), Some("Bar")); + } + + #[test] + fn scan_cache_reuses_results_until_files_change() { + let dir = write_skill(&[( + "scripts/run.py", + "import os\nos.environ[\"CACHE_TEST_API_KEY\"]\n", + )]); + let first = skill_env_requirements(dir.path()); + let second = skill_env_requirements(dir.path()); + assert_eq!(first.len(), second.len()); + assert!(requirement(&second, "CACHE_TEST_API_KEY").is_some()); + } + + #[test] + fn probe_env_names_filters_invalid_and_caps() { + let results = probe_env_names(&[ + "PATH".to_string(), + "not a name".to_string(), + "LIVEAGENT_TEST_SURELY_UNSET_VAR".to_string(), + ]); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|entry| entry.name == "PATH" && entry.present)); + assert!(results + .iter() + .any(|entry| entry.name == "LIVEAGENT_TEST_SURELY_UNSET_VAR" && !entry.present)); + } + + #[test] + fn env_var_name_validation() { + assert!(is_valid_env_var_name("FOO_API_KEY")); + assert!(is_valid_env_var_name("_PRIVATE")); + assert!(!is_valid_env_var_name("")); + assert!(!is_valid_env_var_name("1BAD")); + assert!(!is_valid_env_var_name("BAD NAME")); + assert!(!is_valid_env_var_name("BAD-NAME")); + } +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/library.rs b/crates/agent-gui/src-tauri/src/services/skills/library.rs index a94e76040..ec91c5b31 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/library.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/library.rs @@ -298,6 +298,7 @@ pub(crate) fn skill_summary_from_dir( .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|duration| u64::try_from(duration.as_millis()).ok()), source: read_skill_source_metadata(skill_dir), + env_requirements: skill_env_requirements(skill_dir), }) } diff --git a/crates/agent-gui/src-tauri/src/services/skills/manager.rs b/crates/agent-gui/src-tauri/src/services/skills/manager.rs index c2405e7ac..aae188b0c 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/manager.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/manager.rs @@ -17,7 +17,9 @@ pub(crate) fn action_from_payload( match action { "read" | "list" | "install" | "install_start" | "install_status" | "install_cancel" | "create" | "validate" | "package" | "delete" | "clawhub_search" | "clawhub_install" - | "scan_external" | "scan_external_mcp" | "scan_mcp_file" => Ok(action.to_string()), + | "scan_external" | "scan_external_mcp" | "scan_mcp_file" | "env_status" => { + Ok(action.to_string()) + } _ => Err(format!("SkillsManager action is not supported: {action}")), } } @@ -79,6 +81,27 @@ pub fn system_manage_skill_sync(payload: Value) -> Result { + let names: Vec = payload + .get("names") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect() + }) + .unwrap_or_default(); + if names.is_empty() { + return Err("SkillsManager env_status requires names".to_string()); + } + Ok(SystemManageSkillResponse { + env_probe: Some(probe_env_names(&names)), + ..base + }) + } "clawhub_search" => { let (clawhub_results, clawhub_next_cursor) = search_clawhub_skills_from_payload(payload)?; diff --git a/crates/agent-gui/src-tauri/src/services/skills/mod.rs b/crates/agent-gui/src-tauri/src/services/skills/mod.rs index bff62d5ac..ba9320e16 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/mod.rs @@ -4,6 +4,7 @@ //! - [`util`]:临时目录、时间戳与 payload 字段工具 //! - [`paths`]:skills 根目录解析、路径回显与路径 / 名称清洗 //! - [`metadata`]:frontmatter / skill.json 元数据解析与元数据文件定位 +//! - [`env_requirements`]:技能脚本环境变量依赖探测与系统环境探测 //! - [`library`]:已安装 Skill 库(发现 / 列表 / 读取 / 删除 / 打包 / `_meta.json`) //! - [`sources`]:安装源准备(GitHub / HTTP / 本地 / 压缩包)、下载与安全解压 //! - [`install`]:备份、带冲突策略的复制与 install payload 编排 @@ -17,6 +18,7 @@ mod builtin; mod clawhub; mod create; +mod env_requirements; mod external; mod external_mcp; mod install; @@ -36,6 +38,7 @@ pub use builtin::ensure_builtin_agent_skills_sync; pub(crate) use builtin::*; pub(crate) use clawhub::*; pub(crate) use create::*; +pub(crate) use env_requirements::*; pub(crate) use external::*; pub(crate) use external_mcp::*; pub(crate) use install::*; diff --git a/crates/agent-gui/src-tauri/src/services/skills/types.rs b/crates/agent-gui/src-tauri/src/services/skills/types.rs index 9f276a59d..8354128d8 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/types.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/types.rs @@ -53,6 +53,32 @@ pub struct SystemClawHubSkillCard { pub download_url: String, } +/// 技能脚本依赖的单个环境变量(探测或 `metadata.env` 声明)。 +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillEnvRequirement { + pub name: String, + /// true 时参与「未配置禁止启用」门禁(强信号或声明必填)。 + pub required: bool, + /// "declared" | "strong" | "weak"。 + pub confidence: String, + pub provider: Option, + pub description: Option, + pub url: Option, + /// 证据文件(相对技能目录),最多记录 5 个。 + pub sources: Vec, + /// 列表时对进程环境变量的现场探测结果(有非空值即 true)。 + pub system_value_present: bool, +} + +/// `env_status` 动作的单条探测结果。 +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillEnvProbeResult { + pub name: String, + pub present: bool, +} + #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct SystemSkillSummary { @@ -64,6 +90,7 @@ pub struct SystemSkillSummary { pub built_in: bool, pub installed_at: Option, pub source: Option, + pub env_requirements: Vec, } #[derive(Debug, Serialize, Clone)] @@ -207,6 +234,7 @@ pub struct SystemManageSkillResponse { pub clawhub_download_url: Option, pub external: Option>, pub external_mcp: Option>, + pub env_probe: Option>, } impl SystemManageSkillResponse { diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 402b49b03..0223b4f53 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -127,6 +127,9 @@ function hasSensitiveSettingsUpdates(settings: AppSettings) { ) || settings.ssh.hosts.some( (host) => host.password.trim().length > 0 || host.privateKey.trim().length > 0, + ) || + Object.values(settings.skills.env).some((vars) => + Object.values(vars).some((config) => (config.value?.trim().length ?? 0) > 0), ) ); } @@ -139,6 +142,7 @@ function hasSensitiveSettingsUpdatesPayload(payload: unknown) { providerUsageQuerySecretUpdates?: unknown; sshSecretUpdates?: unknown; sttSecretUpdate?: unknown; + skillEnvSecretUpdates?: unknown; }) : {}; if ( @@ -148,6 +152,21 @@ function hasSensitiveSettingsUpdatesPayload(payload: unknown) { ) { return true; } + const skillEnvUpdates = source.skillEnvSecretUpdates; + if ( + skillEnvUpdates && + typeof skillEnvUpdates === "object" && + !Array.isArray(skillEnvUpdates) && + Object.values(skillEnvUpdates).some( + (vars) => + vars && + typeof vars === "object" && + !Array.isArray(vars) && + Object.values(vars).some((value) => typeof value === "string" && value.trim().length > 0), + ) + ) { + return true; + } const providerUpdates = source.providerApiKeyUpdates; if ( providerUpdates && diff --git a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx index a88708e1d..95d680404 100644 --- a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx +++ b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx @@ -6,6 +6,7 @@ import { isAlwaysEnabledSkillName, type SkillSummary, } from "@liveagent/ui/lib/skills/index"; +import { collectSkillEnvInjection, resolveSkillEnvStatus } from "@liveagent/ui/lib/skills/skillEnv"; import { listen } from "@tauri-apps/api/event"; import { useEffect, useRef } from "react"; import { backend } from "../../lib/automation/backend"; @@ -62,6 +63,8 @@ function getActiveAgentPrompt(settings: AppSettings) { ); } +type CronSkillEnvGate = (baseDir: string) => { skillName: string; missing: string[] } | null; + async function buildCronSkillsContext(settings: AppSettings, workdir: string) { const resources = resolveWorkspaceResources(settings, workdir); const selectedSkillNames = resources.skillNames.filter((name) => !isAlwaysEnabledSkillName(name)); @@ -71,6 +74,8 @@ async function buildCronSkillsContext(settings: AppSettings, workdir: string) { prompt: "", rootDir: "", accessPolicy: undefined as SkillAccessPolicy | undefined, + envGate: undefined as CronSkillEnvGate | undefined, + envInjection: undefined as (() => Record) | undefined, }; } @@ -90,14 +95,25 @@ async function buildCronSkillsContext(settings: AppSettings, workdir: string) { prompt: "", rootDir: "", accessPolicy: undefined as SkillAccessPolicy | undefined, + envGate: undefined as CronSkillEnvGate | undefined, + envInjection: undefined as (() => Record) | undefined, }; } + const envSettings = settings.skills.env; + const envGate: CronSkillEnvGate = (baseDir) => { + const skill = selectedSkills.find((item) => item.baseDir === baseDir); + if (!skill) return null; + const status = resolveSkillEnvStatus(skill, envSettings); + return status.satisfied ? null : { skillName: skill.name, missing: status.missingRequired }; + }; + return { enabled: true, prompt: buildSkillsSystemPrompt({ rootDir: discovery.rootDir, selected: selectedSkills, + envSettings, }), rootDir: discovery.rootDir, accessPolicy: { @@ -107,6 +123,12 @@ async function buildCronSkillsContext(settings: AppSettings, workdir: string) { allowSkillManagement: false, allowSkillMutation: true, }, + envGate: envGate as CronSkillEnvGate | undefined, + envInjection: (() => + collectSkillEnvInjection( + selectedSkills.filter((skill) => resolveSkillEnvStatus(skill, envSettings).satisfied), + envSettings, + )) as (() => Record) | undefined, }; } @@ -175,6 +197,8 @@ async function executeCronPromptRun( skillsEnabled: skillsContext.enabled, skillsRootDir: skillsContext.rootDir, skillAccessPolicy: skillsContext.accessPolicy, + skillEnvGate: skillsContext.envGate, + resolveSkillEnvInjection: skillsContext.envInjection, sandbox: resolveShellSandboxSettings(settings.system.commandSafetyMode), runtimeScope: "cron_auto_prompt", currentChatModel: { diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index b59b165f3..7d68c5203 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -167,6 +167,10 @@ type BuildBuiltinBaseToolRegistryParams = { skillsEnabled: boolean; skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; + /** 技能环境变量门禁:按 baseDir 返回未满足的必需变量;null 放行。 */ + skillEnvGate?: (baseDir: string) => { skillName: string; missing: string[] } | null; + /** 会话级技能环境变量注入(Bash 子进程),只含用户填写的值。 */ + resolveSkillEnvInjection?: () => Record; onManagedSkillsChanged?: (change: { action: "install" | "create" | "delete"; names: string[]; @@ -233,12 +237,14 @@ async function buildBaseBuiltinToolBundles( resumableShellEnabled: params.runtimeScope === "chat", resolveHomeDir, sandbox: params.sandbox, + resolveExtraEnvs: params.resolveSkillEnvInjection, }), ...(params.skillsEnabled ? [ createSkillTools({ workdir: params.workdir, skillAccessPolicy: params.skillAccessPolicy, + skillEnvGate: params.skillEnvGate, onManagedSkillsChanged: params.onManagedSkillsChanged, }), ] diff --git a/crates/agent-gui/src/lib/tools/shellTools.ts b/crates/agent-gui/src/lib/tools/shellTools.ts index 1ce0fa93a..6e7564852 100644 --- a/crates/agent-gui/src/lib/tools/shellTools.ts +++ b/crates/agent-gui/src/lib/tools/shellTools.ts @@ -523,6 +523,8 @@ export function createShellTools(params: { resolveHomeDir?: () => Promise; /** OS 级沙箱;undefined 或 enabled=false 时直跑。Windows 联网后端不掩蔽凭据目录。 */ sandbox?: ShellSandboxSettings; + /** 会话级额外环境变量(技能配置注入),每次执行时实时取值。 */ + resolveExtraEnvs?: () => Record; }): BuiltinToolBundle { const timeoutPolicy = resolveBashTimeoutPolicy(params.providerId); const runtimePlatform = @@ -1530,6 +1532,7 @@ export function createShellTools(params: { abortHandler(); } } + const extraEnvs = params.resolveExtraEnvs?.(); const res = await invoke("shell_run", { workdir, command, @@ -1540,6 +1543,7 @@ export function createShellTools(params: { run_id, sandbox: sandboxEnabled, sandbox_allow_network: sandboxAllowNetwork, + extra_envs: extraEnvs && Object.keys(extraEnvs).length > 0 ? extraEnvs : undefined, }); const header = [ diff --git a/crates/agent-gui/src/lib/tools/skillTools.ts b/crates/agent-gui/src/lib/tools/skillTools.ts index ba01984dc..95dcf0b54 100644 --- a/crates/agent-gui/src/lib/tools/skillTools.ts +++ b/crates/agent-gui/src/lib/tools/skillTools.ts @@ -592,10 +592,24 @@ function buildActionDetails( }; } +function buildSkillEnvMissingText(gate: { skillName: string; missing: string[] }) { + return [ + `Skill "${gate.skillName}" is currently unavailable: missing required environment variable(s): ${gate.missing.join(", ")}.`, + "The app is showing the user a setup card for this (fill the values in the Skill detail page, or adopt existing system environment variables).", + "Tell the user briefly what is missing, then wait for them to finish configuring before retrying.", + "Never ask the user to paste secret values into the chat.", + ].join(" "); +} + export function createSkillTools( params: { workdir?: string; skillAccessPolicy?: SkillAccessPolicy; + /** + * 环境变量门禁:按 baseDir 返回该技能未满足的必需变量;null 放行。 + * 命中时 action=read 被拦截,返回 skill_env_missing 结构化结果。 + */ + skillEnvGate?: (baseDir: string) => { skillName: string; missing: string[] } | null; onManagedSkillsChanged?: (change: { action: "install" | "create" | "delete"; names: string[]; @@ -647,6 +661,26 @@ export function createSkillTools( ); enforceSkillManagerAccessPolicy(payload, skillAccessPolicy); + if (payload.action === "read" && params.skillEnvGate) { + const gate = params.skillEnvGate(skillBaseDirFromPath(String(payload.path ?? ""))); + if (gate) { + const details: SkillsManagerResultDetails = { + kind: "skill_env_missing", + skillName: gate.skillName, + missing: gate.missing, + }; + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: buildSkillEnvMissingText(gate) }], + details, + isError: true, + timestamp: now, + }; + } + } + const result = await manageSkill(payload); const visibleResult = filterManageSkillResult(result, skillAccessPolicy); if ( diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index d3d22e139..7a1003b97 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -44,7 +44,11 @@ import { import { createSidebarStore } from "@liveagent/ui/lib/sidebar/store"; import type { SidebarConversation } from "@liveagent/ui/lib/sidebar/types"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; -import { buildSkillsSystemPrompt, type SkillSummary } from "@liveagent/ui/lib/skills/index"; +import { + buildSkillsSystemPrompt, + type SkillSummary, + subscribeSkillEnvConfigRequested, +} from "@liveagent/ui/lib/skills/index"; import { useChatSkills } from "@liveagent/ui/lib/skills/useChatSkills"; import { terminalSessionBelongsToProject } from "@liveagent/ui/lib/terminal/sessionStore"; import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; @@ -1523,6 +1527,7 @@ export function ChatPage(props: ChatPageProps) { skillsPrompt = buildSkillsSystemPrompt({ rootDir: skillsRootDir, selected: selectedSkills, + envSettings: settings.skills.env, }); } } @@ -1863,6 +1868,16 @@ export function ChatPage(props: ChatPageProps) { }; }, [composerRef, setActiveView]); + // 聊天里的技能环境变量引导卡请求跳转:切到 Skills Hub,抽屉由 Hub 页 + // 消费 pending 目标自行打开。 + useEffect(() => { + return subscribeSkillEnvConfigRequested(() => { + cacheActiveComposerDraft(); + setRightDockOpen(false); + setActiveView("skills-hub"); + }); + }); + // 托盘菜单同步:任一输入变化即重建模型推送(syncTrayMenu 内部按 JSON 签名 // 去抖),300ms 尾随防抖吸收流式期间侧栏 upsert 引起的高频变化。 // 注:全局快捷键绑定存 localStorage 无订阅,在模型构建时现读——改绑后 diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index ff8a53457..3ebb847e4 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -19,6 +19,7 @@ import { resolveExplicitSkillMentions, type SkillSummary, } from "@liveagent/ui/lib/skills/index"; +import { collectSkillEnvInjection, resolveSkillEnvStatus } from "@liveagent/ui/lib/skills/skillEnv"; import { invoke } from "@tauri-apps/api/core"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { useCallback } from "react"; @@ -1238,6 +1239,10 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { /** 本轮 `/skill-name` 显式提及块;没有提及时恒为空串,不会挂出任何内容。 */ let explicitSkillMentionBlock = ""; let skillsRootDirForTools = skillsRootDir; + let skillEnvGateForTools: + | ((baseDir: string) => { skillName: string; missing: string[] } | null) + | undefined; + let skillEnvInjectionForTools: (() => Record) | undefined; let skillAccessPolicyForTools: SkillAccessPolicy | undefined = effectiveSkillsEnabled ? { allowedSkillNames: [], @@ -1467,6 +1472,22 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { allowSkillManagement: allowBuiltinSkillManagement, allowSkillMutation: true, }; + // 环境变量门禁与注入都按当轮启用技能 + 发送时的配置快照构造; + // 用户在卡片引导下补配置后,下一轮自然拿到新状态。 + const skillEnvSettings = settings.skills.env; + skillEnvGateForTools = (baseDir: string) => { + const skill = selectedSkills.find((item) => item.baseDir === baseDir); + if (!skill) return null; + const status = resolveSkillEnvStatus(skill, skillEnvSettings); + return status.satisfied ? null : { skillName: skill.name, missing: status.missingRequired }; + }; + skillEnvInjectionForTools = () => + collectSkillEnvInjection( + selectedSkills.filter( + (skill) => resolveSkillEnvStatus(skill, skillEnvSettings).satisfied, + ), + skillEnvSettings, + ); const explicitSkills = resolveExplicitSkillMentions({ text, structured: composerDraft?.skillMentions ?? [], @@ -1478,6 +1499,7 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { skillsPrompt = buildSkillsSystemPrompt({ rootDir, selected: selectedSkills, + envSettings: skillEnvSettings, }); } @@ -1677,6 +1699,8 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { showSilentMemoryExtraction: effectiveIsAgentDevExecutionMode, skillsRootDir: skillsRootDirForTools, skillAccessPolicy: skillAccessPolicyForTools, + skillEnvGate: skillEnvGateForTools, + resolveSkillEnvInjection: skillEnvInjectionForTools, onManagedSkillsChanged: (change) => { if (change.action !== "delete") { enableManagedSkills(change.names); diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index c61f2611c..bd88ed829 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -285,6 +285,9 @@ export type RunAgentConversationTurnParams = { showSilentMemoryExtraction: boolean; skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; + /** 技能环境变量门禁与注入(调用方按当轮启用技能构造)。 */ + skillEnvGate?: (baseDir: string) => { skillName: string; missing: string[] } | null; + resolveSkillEnvInjection?: () => Record; onManagedSkillsChanged?: (change: { action: "install" | "create" | "delete"; names: string[]; @@ -381,6 +384,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP showSilentMemoryExtraction, skillsRootDir, skillAccessPolicy, + skillEnvGate, + resolveSkillEnvInjection, onManagedSkillsChanged, agentTemplates, getMcpSettings, @@ -621,6 +626,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP skillsEnabled: effectiveSkillsEnabled, skillsRootDir, skillAccessPolicy, + skillEnvGate, + resolveSkillEnvInjection, onManagedSkillsChanged, runtimeScope: "chat", currentChatModel: selectedModel, diff --git a/crates/agent-gui/test/skills/skill-card-interactions.test.mjs b/crates/agent-gui/test/skills/skill-card-interactions.test.mjs index 07617d274..6c726ccfd 100644 --- a/crates/agent-gui/test/skills/skill-card-interactions.test.mjs +++ b/crates/agent-gui/test/skills/skill-card-interactions.test.mjs @@ -26,13 +26,26 @@ test("installed Skill cards follow the global Skills activation state", () => { assert.match(cardSource, /const effectivelyEnabled = skillsEnabled && checked/); assert.match( cardSource, - / { + const cardSource = readUiSource("pages/skills-hub/InstalledSkillCard.tsx"); + const pageSource = readUiSource("pages/skills-hub/SkillsHubPage.tsx"); + + // 未启用且必需环境变量未满足:开关置灰,覆盖层点击改为打开详情抽屉。 + assert.match(cardSource, /const envGated = !alwaysEnabled && !envSatisfied && !checked/); + assert.match(cardSource, /envGated && skillsEnabled \? \([\s\S]*onOpenPreview\(skill\)/); + assert.match(cardSource, /settings\.skillsEnvBadgePending/); + // 页面侧:toggle 与批量启用都跳过未满足的技能。 + assert.match(pageSource, /if \(on && skillEnvSatisfied\.get\(name\) === false\)/); + assert.match(pageSource, /!target \|\| skillEnvSatisfied\.get\(name\) !== false/); +}); + test("installed Skill switches stay right-aligned while delete appears in the card footer", () => { const source = readUiSource("pages/skills-hub/InstalledSkillCard.tsx"); const switchIndex = source.indexOf(" { + const detectedOnly = skillEnv.resolveSkillEnvStatus( + summary("weather", [requirement("OPENWEATHER_API_KEY")]), + {}, + ); + assert.equal(detectedOnly.satisfied, true); + assert.equal(detectedOnly.requirements[0].effectiveRequired, false); + + const declared = skillEnv.resolveSkillEnvStatus( + summary("weather", [requirement("OPENWEATHER_API_KEY", { confidence: "declared" })]), + {}, + ); + assert.equal(declared.satisfied, false); + assert.deepEqual(declared.missingRequired, ["OPENWEATHER_API_KEY"]); + assert.equal(declared.requirements[0].state, "missing"); +}); + +test("用户填写值或脱敏 configured 标记都算已满足", () => { + const byValue = skillEnv.resolveSkillEnvStatus( + summary("weather", [requirement("OPENWEATHER_API_KEY", { confidence: "declared" })]), + { weather: { OPENWEATHER_API_KEY: { value: "sk-test" } } }, + ); + assert.equal(byValue.satisfied, true); + assert.equal(byValue.requirements[0].state, "user"); + + const byConfigured = skillEnv.resolveSkillEnvStatus( + summary("weather", [requirement("OPENWEATHER_API_KEY", { confidence: "declared" })]), + { weather: { OPENWEATHER_API_KEY: { configured: true } } }, + ); + assert.equal(byConfigured.satisfied, true); + assert.equal(byConfigured.requirements[0].state, "user"); +}); + +test("系统环境变量存在即满足,probeOverrides 可覆盖后端探测", () => { + const bySystem = skillEnv.resolveSkillEnvStatus( + summary("weather", [ + requirement("OPENWEATHER_API_KEY", { confidence: "declared", systemValuePresent: true }), + ]), + {}, + ); + assert.equal(bySystem.satisfied, true); + assert.equal(bySystem.requirements[0].state, "system"); + + const byProbe = skillEnv.resolveSkillEnvStatus( + summary("weather", [requirement("OPENWEATHER_API_KEY", { confidence: "declared" })]), + {}, + { OPENWEATHER_API_KEY: true }, + ); + assert.equal(byProbe.satisfied, true); + assert.equal(byProbe.requirements[0].state, "system"); +}); + +test("误报豁免让声明必填退出门禁,探测建议可采纳为必需", () => { + const ignored = skillEnv.resolveSkillEnvStatus( + summary("weather", [requirement("SORT_KEY", { confidence: "declared" })]), + { weather: { SORT_KEY: { override: "ignored" } } }, + ); + assert.equal(ignored.satisfied, true); + assert.equal(ignored.requirements[0].effectiveRequired, false); + assert.equal(ignored.requirements[0].ignored, true); + + const adopted = skillEnv.resolveSkillEnvStatus( + summary("weather", [requirement("WEATHER_REGION", { required: false, confidence: "weak" })]), + { weather: { WEATHER_REGION: { override: "required" } } }, + ); + assert.equal(adopted.satisfied, false); + assert.deepEqual(adopted.missingRequired, ["WEATHER_REGION"]); +}); + +test("手动添加的变量参与门禁,标记忽略的手动条目不显示", () => { + const status = skillEnv.resolveSkillEnvStatus(summary("weather", []), { + weather: { + MANUAL_TOKEN: { override: "required" }, + NOISE_VAR: { override: "ignored" }, + }, + }); + assert.deepEqual( + status.requirements.map((entry) => entry.name), + ["MANUAL_TOKEN"], + ); + assert.equal(status.requirements[0].confidence, "user"); + assert.equal(status.satisfied, false); +}); + +test("注入只包含用户填写的值,忽略非法变量名,冲突按技能名序后者覆盖", () => { + const injection = skillEnv.collectSkillEnvInjection( + [{ name: "beta" }, { name: "alpha" }], + { + alpha: { + SHARED_KEY: { value: "from-alpha" }, + "BAD NAME": { value: "x" }, + EMPTY_ONE: { value: " " }, + FLAG_ONLY: { configured: true }, + }, + beta: { SHARED_KEY: { value: "from-beta" }, BETA_TOKEN: { value: "b" } }, + }, + ); + assert.deepEqual(injection, { SHARED_KEY: "from-beta", BETA_TOKEN: "b" }); +}); + +test("normalize 清理空条目并保留 value/configured/override", () => { + const normalized = skillEnv.normalizeSkillEnvSettings({ + weather: { + GOOD_KEY: { value: "v", override: "required", junk: 1 }, + CONFIGURED_ONLY: { configured: true }, + EMPTY: {}, + "bad name": { value: "x" }, + }, + "": { X_KEY: { value: "y" } }, + }); + assert.deepEqual(Object.keys(normalized), ["weather"]); + assert.deepEqual(normalized.weather.GOOD_KEY, { value: "v", override: "required" }); + assert.deepEqual(normalized.weather.CONFIGURED_ONLY, { configured: true }); + assert.equal(normalized.weather.EMPTY, undefined); + assert.equal(normalized.weather["bad name"], undefined); +}); + +test("envRequirements 摘要随必需性与系统探测变化", () => { + const a = skillEnv.skillEnvRequirementsSignature([requirement("K_KEY")]); + const b = skillEnv.skillEnvRequirementsSignature([ + requirement("K_KEY", { systemValuePresent: true }), + ]); + assert.notEqual(a, b); + assert.equal(skillEnv.skillEnvRequirementsSignature([]), ""); + assert.equal(skillEnv.skillEnvRequirementsSignature(undefined), ""); +}); + +test("手动添加输入支持 NAME、NAME=值 与多行 .env 粘贴", () => { + assert.deepEqual(skillEnv.parseSkillEnvAddEntries("MY_API_KEY"), [{ name: "MY_API_KEY" }]); + assert.deepEqual(skillEnv.parseSkillEnvAddEntries("MY_API_KEY=sk-123"), [ + { name: "MY_API_KEY", value: "sk-123" }, + ]); + assert.deepEqual( + skillEnv.parseSkillEnvAddEntries( + [ + "# 注释行忽略", + "export FOO_TOKEN='tok-1'", + 'BAR_KEY="k=with=equals"', + "BAZ_URL=", + "bad name=x", + "FOO_TOKEN=重复忽略", + "", + ].join("\n"), + ), + [ + { name: "FOO_TOKEN", value: "tok-1" }, + { name: "BAR_KEY", value: "k=with=equals" }, + { name: "BAZ_URL" }, + ], + ); + assert.deepEqual(skillEnv.parseSkillEnvAddEntries(" "), []); +}); diff --git a/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx index 559335ea8..7bef17b2c 100644 --- a/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx @@ -10,6 +10,8 @@ import { ToolSurfaceLabel, } from "@liveagent/ui/components/chat/ToolSurfaces"; import { Markdown } from "@liveagent/ui/components/Markdown"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { useLocale } from "@liveagent/ui/i18n/index"; import { type DeleteResultDetails, deriveFileToolPreview, @@ -33,12 +35,18 @@ import { toolResultMessageToText, type WriteResultDetails, } from "@liveagent/ui/lib/chat/assistantBubbleAdapter"; +import { + notifySkillsDiscoveryUpdated, + probeSkillEnvNames, + requestSkillEnvConfigNavigation, +} from "@liveagent/ui/lib/skills/index"; import type { SubagentBatchDetails, SubagentCardDetails, SubagentMessageDetails, } from "@liveagent/ui/lib/subagents/protocol"; -import { Search } from "../../IconSet"; +import { useState } from "react"; +import { Key, Loader2, Search } from "../../IconSet"; import { displayString, getBuiltinResultKind, @@ -445,6 +453,92 @@ function fileScopeTags(details: unknown): MetaTag[] { return scope ? [{ label: "scope", value: scope }] : []; } +// 技能缺少必需环境变量时的引导卡:跳转详情页填写,或现场探测系统环境变量。 +// 探测结果只存在组件内,不回写消息——重开会话时按钮仍可重试。 +function SkillEnvMissingCard(props: { skillName: string; missing: string[]; readOnly: boolean }) { + const { skillName, missing, readOnly } = props; + const { t } = useLocale(); + const [probing, setProbing] = useState(false); + const [probeOutcome, setProbeOutcome] = useState<{ absent: string[] } | null>(null); + const resolved = probeOutcome !== null && probeOutcome.absent.length === 0; + + const adoptSystemEnv = async () => { + if (probing) return; + setProbing(true); + try { + const results = await probeSkillEnvNames(missing); + const absent = missing.filter((name) => !results[name]); + setProbeOutcome({ absent }); + if (absent.length === 0) { + // 系统已满足:触发发现刷新,让 Hub 徽标与启用门禁同步解除。 + notifySkillsDiscoveryUpdated(); + } + } catch { + // 探测失败保持原状,允许重试。 + } finally { + setProbing(false); + } + }; + + return ( + +
+ +
+
+ {t("chat.skillEnvMissingTitle").replace("{skill}", skillName)} +
+
+ {missing.map((name) => ( + + {name} + + ))} +
+ {resolved ? ( +
+ {t("chat.skillEnvProbeResolved")} +
+ ) : ( + <> + {probeOutcome ? ( +
+ {t("chat.skillEnvProbeAbsent").replace("{names}", probeOutcome.absent.join(", "))} +
+ ) : null} + {!readOnly ? ( +
+ + +
+ ) : null} + + )} +
+
+
+ ); +} + export function ToolResultDisplay({ item, result, @@ -616,6 +710,20 @@ export function ToolResultDisplay({ ); } + if (kind === "skill_env_missing") { + const details = result.details as Extract< + SkillsManagerResultDetails, + { kind: "skill_env_missing" } + >; + return ( + + ); + } + if (kind === "manage_mcp") { const details = result.details as McpManagerResultDetails; return ( diff --git a/crates/agent-ui/src/contracts/builtinTools.ts b/crates/agent-ui/src/contracts/builtinTools.ts index 7b09690a0..c81e62746 100644 --- a/crates/agent-ui/src/contracts/builtinTools.ts +++ b/crates/agent-ui/src/contracts/builtinTools.ts @@ -200,9 +200,17 @@ export type SkillsManagerActionResultDetails = { errors?: string[]; }; +/** 技能因缺少必需环境变量被拦截时的结构化结果(聊天侧渲染配置引导卡片)。 */ +export type SkillsManagerEnvMissingDetails = { + kind: "skill_env_missing"; + skillName: string; + missing: string[]; +}; + export type SkillsManagerResultDetails = | SkillsManagerReadResultDetails - | SkillsManagerActionResultDetails; + | SkillsManagerActionResultDetails + | SkillsManagerEnvMissingDetails; export type McpManagerResultDetails = { kind: "manage_mcp"; diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index ffa38bc0c..67daa9ef7 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -117,6 +117,14 @@ export const EN_US_COMMON_TRANSLATIONS = { "tooltip.closeSidebar": "Close Sidebar", "tooltip.openSidebar": "Open Sidebar", "chat.newConversation": "New Conversation", + "chat.skillEnvMissingTitle": + "Skill {skill} is unavailable: missing required environment variables", + "chat.skillEnvOpenConfig": "Configure in Skill details", + "chat.skillEnvUseSystem": "Use system environment", + "chat.skillEnvProbeResolved": + "System environment satisfies the requirements — reply “continue” to let the model retry.", + "chat.skillEnvProbeAbsent": + "Not found in system environment either: {names}. Fill them in Skill details.", "chat.pendingTitle": "New Chat", "chat.recentConversation": "Conversations", "chat.workspaceSection": "Workspaces", diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 18c55a195..3ad7593f3 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -988,6 +988,32 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.skillsInstalledPreviewBuiltIn": "Built-in", "settings.skillsInstalledPreviewName": "Skill name", "settings.skillsInstalledPreviewDescription": "Description", + "settings.skillsEnvSectionTitle": "Runtime requirements", + "settings.skillsEnvRefreshProbe": "Re-check system env", + "settings.skillsEnvGateHint": + "Missing {count} required environment variable(s). Configure them to enable this skill. Values are stored in local settings only.", + "settings.skillsEnvBadgePending": "Needs setup", + "settings.skillsEnvConfigureToEnable": + "Configure required environment variables first — click to open details", + "settings.skillsEnvStateUser": "Configured", + "settings.skillsEnvStateSystem": "System env", + "settings.skillsEnvStateMissing": "Not set", + "settings.skillsEnvValuePlaceholder": "Paste the key or value; Enter/blur saves", + "settings.skillsEnvValueSavedPlaceholder": "Saved •••••••• — type to replace", + "settings.skillsEnvApplyUrl": "Get a key", + "settings.skillsEnvSources": "Found in", + "settings.skillsEnvClearValue": "Clear value", + "settings.skillsEnvMarkIgnored": "Not needed", + "settings.skillsEnvRemoveManual": "Remove", + "settings.skillsEnvRestoreIgnored": "Restore", + "settings.skillsEnvAdopt": "Adopt", + "settings.skillsEnvIgnoredBadge": "Ignored", + "settings.skillsEnvSuggestGroup": "Possible dependencies detected ({count})", + "settings.skillsEnvEmptyHint": + "No environment variables declared for this skill. Add them below when needed (multi-line NAME=value paste supported); values are injected into the script environment.", + "settings.skillsEnvCardConfigure": "Configure environment variables", + "settings.skillsEnvAddPlaceholder": "Add NAME or NAME=value; multi-line paste supported", + "settings.skillsEnvAddAction": "Add", "settings.skillsInstalledPreviewNoDescription": "No description", "settings.skillsInstalledPreviewCopyDescription": "Copy Skill description", "settings.skillsInstalledPreviewCopyFile": "Copy Skill file contents", diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index f077553d7..db6809389 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -110,6 +110,11 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "tooltip.closeSidebar": "关闭边栏", "tooltip.openSidebar": "打开边栏", "chat.newConversation": "新对话", + "chat.skillEnvMissingTitle": "技能 {skill} 暂不可用:缺少必要环境变量", + "chat.skillEnvOpenConfig": "去技能详情页填写", + "chat.skillEnvUseSystem": "读取系统环境变量", + "chat.skillEnvProbeResolved": "系统环境变量已满足要求,回复「继续」让模型重试即可。", + "chat.skillEnvProbeAbsent": "系统环境中也未找到:{names}。请在技能详情页填写。", "chat.pendingTitle": "新会话", "chat.recentConversation": "最近会话", "chat.workspaceSection": "工作空间", diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index 1627018f9..a6ff1473a 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -941,6 +941,31 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.skillsInstalledPreviewBuiltIn": "内置", "settings.skillsInstalledPreviewName": "技能名称", "settings.skillsInstalledPreviewDescription": "技能描述", + "settings.skillsEnvSectionTitle": "运行要求", + "settings.skillsEnvRefreshProbe": "重新检测系统环境", + "settings.skillsEnvGateHint": + "缺少 {count} 项必需环境变量,补齐后才能启用该技能。填写的值仅保存在本机设置中。", + "settings.skillsEnvBadgePending": "待配置", + "settings.skillsEnvConfigureToEnable": "需先配置必需环境变量,点击打开详情填写", + "settings.skillsEnvStateUser": "已填写", + "settings.skillsEnvStateSystem": "系统环境变量", + "settings.skillsEnvStateMissing": "未配置", + "settings.skillsEnvValuePlaceholder": "粘贴密钥或值,回车/失焦保存", + "settings.skillsEnvValueSavedPlaceholder": "已保存 ••••••••,重新输入可覆盖", + "settings.skillsEnvApplyUrl": "申请地址", + "settings.skillsEnvSources": "出现于", + "settings.skillsEnvClearValue": "清除值", + "settings.skillsEnvMarkIgnored": "标记误报", + "settings.skillsEnvRemoveManual": "移除", + "settings.skillsEnvRestoreIgnored": "恢复", + "settings.skillsEnvAdopt": "采纳", + "settings.skillsEnvIgnoredBadge": "已忽略", + "settings.skillsEnvSuggestGroup": "检测到的可能依赖({count})", + "settings.skillsEnvEmptyHint": + "此技能未声明环境变量。需要时在下方添加(支持粘贴多行 NAME=值),值会注入脚本运行环境。", + "settings.skillsEnvCardConfigure": "配置环境变量", + "settings.skillsEnvAddPlaceholder": "添加变量:NAME 或 NAME=值,可粘贴多行", + "settings.skillsEnvAddAction": "添加", "settings.skillsInstalledPreviewNoDescription": "暂无描述", "settings.skillsInstalledPreviewCopyDescription": "复制技能描述", "settings.skillsInstalledPreviewCopyFile": "复制技能文件内容", diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index bfbd80883..a7e38db35 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -29,6 +29,11 @@ import { import { normalizeFontFamily } from "@liveagent/ui/lib/shared/fontFamily"; import { createUuid } from "@liveagent/ui/lib/shared/id"; import { mergeAlwaysEnabledSkillNames } from "@liveagent/ui/lib/skills/builtin"; +import { + isValidSkillEnvVarName, + normalizeSkillEnvSettings, + type SkillEnvVarConfig, +} from "@liveagent/ui/lib/skills/skillEnv"; import { DEFAULT_CHAT_TRANSCRIPT_WIDTH, MAX_CHAT_TRANSCRIPT_WIDTH, @@ -1416,6 +1421,7 @@ export function normalizeSkillsSettings(input: unknown): SkillsSettings { return { enabled: obj.enabled !== false, selected: mergeAlwaysEnabledSkillNames(normalizeStringArray(obj.selected)), + env: normalizeSkillEnvSettings(obj.env), }; } @@ -1585,6 +1591,7 @@ export function getDefaultSettings(): AppSettings { skills: { enabled: true, selected: mergeAlwaysEnabledSkillNames([]), + env: {}, }, chatRuntimeControls: DEFAULT_CHAT_RUNTIME_CONTROLS, selectedModel: undefined, @@ -1757,6 +1764,36 @@ export function updateSkills(prev: AppSettings, patch: Partial): }); } +/** + * 写入/清除单个技能环境变量配置。`config` 传 null 删除该变量条目; + * 值与覆盖标记都为空的条目会被 normalize 清理,技能条目空了同样移除。 + */ +export function updateSkillEnvVar( + prev: AppSettings, + skillName: string, + varName: string, + config: SkillEnvVarConfig | null, +): AppSettings { + const normalizedSkillName = skillName.trim(); + const normalizedVarName = varName.trim(); + if (!normalizedSkillName || !isValidSkillEnvVarName(normalizedVarName)) return prev; + + const skillVars = { ...(prev.skills.env[normalizedSkillName] ?? {}) }; + if (config === null) { + if (!(normalizedVarName in skillVars)) return prev; + delete skillVars[normalizedVarName]; + } else { + skillVars[normalizedVarName] = config; + } + + return updateSkills(prev, { + env: { + ...prev.skills.env, + [normalizedSkillName]: skillVars, + }, + }); +} + export function resolveWorkspaceResources( settings: AppSettings, workdir: string, diff --git a/crates/agent-ui/src/lib/settings/sync.ts b/crates/agent-ui/src/lib/settings/sync.ts index ed6477c78..50672cf00 100644 --- a/crates/agent-ui/src/lib/settings/sync.ts +++ b/crates/agent-ui/src/lib/settings/sync.ts @@ -5,9 +5,11 @@ import { normalizeChatRuntimeControls, normalizeRightDockSettings, normalizeSettings, + normalizeSkillsSettings, normalizeWorkspaceResourceSettings, workspaceProjectPathKey, } from "@liveagent/app/lib/settings/index"; +import type { SkillEnvSettingsMap, SkillEnvVarConfig } from "@liveagent/ui/lib/skills/skillEnv"; export type GatewayProviderApiKeyUpdates = Record; export type GatewayProviderUsageQuerySecretUpdates = Record< @@ -48,6 +50,7 @@ export type GatewaySettingsSyncProvider = Omit; export type GatewaySttSecretUpdate = AppSettings["stt"]; +export type GatewaySkillEnvSecretUpdates = Record>; export type GatewaySettingsSyncPayload = { system: AppSettings["system"]; @@ -78,6 +81,8 @@ export type GatewaySettingsSyncPayload = { systemProxyPasswordUpdate?: string; /** WebUI → 桌面端的一次性 STT 凭据更新;任何公开广播前必须移除。 */ sttSecretUpdate?: GatewaySttSecretUpdate; + /** 技能环境变量明文 sidecar(技能名 → 变量名 → 值);skills 字段出口必被脱敏。 */ + skillEnvSecretUpdates?: GatewaySkillEnvSecretUpdates; }; export type GatewaySettingsSyncUpdatePayload = Partial; @@ -186,9 +191,127 @@ export function redactSettingsForWebStorage(settings: AppSettings): AppSettings customProviders: redactCustomProvidersForWebStorage(settings.customProviders), ssh: redactSshSettingsForWebStorage(settings.ssh), stt: redactSttSettingsForWebStorage(settings.stt), + skills: redactSkillsSettingsSecrets(settings.skills), }); } +/** 技能环境变量值出口脱敏:值本体清空,configured 标记保留已配置状态。 */ +export function redactSkillsSettingsSecrets(skills: AppSettings["skills"]): AppSettings["skills"] { + const env: SkillEnvSettingsMap = {}; + for (const [skillName, vars] of Object.entries(skills.env)) { + const nextVars: Record = {}; + for (const [varName, config] of Object.entries(vars)) { + const configured = (config.value?.trim().length ?? 0) > 0 || config.configured === true; + const next: SkillEnvVarConfig = {}; + if (configured) next.configured = true; + if (config.override) next.override = config.override; + if (next.configured !== undefined || next.override !== undefined) { + nextVars[varName] = next; + } + } + if (Object.keys(nextVars).length > 0) { + env[skillName] = nextVars; + } + } + return { ...skills, env }; +} + +/** 收集当前持有的全部技能环境变量明文(对齐 collectProviderApiKeyUpdates 语义)。 */ +export function collectSkillEnvSecretUpdates( + skills: AppSettings["skills"], +): GatewaySkillEnvSecretUpdates | undefined { + const updates: GatewaySkillEnvSecretUpdates = {}; + for (const [skillName, vars] of Object.entries(skills.env)) { + for (const [varName, config] of Object.entries(vars)) { + if (typeof config.value === "string" && config.value.trim()) { + const bucket = updates[skillName] ?? {}; + bucket[varName] = config.value; + updates[skillName] = bucket; + } + } + } + return Object.keys(updates).length > 0 ? updates : undefined; +} + +/** 只收集相对 prev 新增/变化的技能环境变量明文(更新载荷用)。 */ +export function collectChangedSkillEnvSecretUpdates( + prev: AppSettings["skills"], + next: AppSettings["skills"], +): GatewaySkillEnvSecretUpdates | undefined { + const updates: GatewaySkillEnvSecretUpdates = {}; + for (const [skillName, vars] of Object.entries(next.env)) { + for (const [varName, config] of Object.entries(vars)) { + const value = typeof config.value === "string" && config.value.trim() ? config.value : ""; + if (!value) continue; + if (prev.env[skillName]?.[varName]?.value === value) continue; + const bucket = updates[skillName] ?? {}; + bucket[varName] = value; + updates[skillName] = bucket; + } + } + return Object.keys(updates).length > 0 ? updates : undefined; +} + +function normalizeSkillEnvSecretUpdates(input: unknown): GatewaySkillEnvSecretUpdates { + const out: GatewaySkillEnvSecretUpdates = {}; + for (const [skillName, vars] of Object.entries(asObject(input))) { + if (!skillName.trim()) continue; + for (const [varName, value] of Object.entries(asObject(vars))) { + if (typeof value === "string" && value.trim()) { + const bucket = out[skillName] ?? {}; + bucket[varName] = value; + out[skillName] = bucket; + } + } + } + return out; +} + +/** + * 合并同步来的 skills 域:结构以 incoming 为准;脱敏回声(configured=true 且无值) + * 沿用本地已存值;sidecar 明文最后落座。configured 丢失且无 sidecar = 显式清除。 + */ +function mergeSyncedSkillsSettings( + current: AppSettings["skills"], + incoming: unknown, + secretUpdates: GatewaySkillEnvSecretUpdates, +): AppSettings["skills"] { + const next = normalizeSkillsSettings(incoming ?? current); + const env: SkillEnvSettingsMap = {}; + for (const [skillName, vars] of Object.entries(next.env)) { + const nextVars: Record = {}; + for (const [varName, config] of Object.entries(vars)) { + const sidecar = secretUpdates[skillName]?.[varName]; + const merged: SkillEnvVarConfig = { ...config }; + if (typeof sidecar === "string" && sidecar.trim()) { + merged.value = sidecar; + merged.configured = true; + } else if (!merged.value?.trim() && merged.configured === true) { + const existing = current.env[skillName]?.[varName]?.value; + if (existing?.trim()) { + merged.value = existing; + } + } + nextVars[varName] = merged; + } + env[skillName] = nextVars; + } + // sidecar 中出现但 incoming 结构里没有的条目(极端时序),直接落座。 + for (const [skillName, vars] of Object.entries(secretUpdates)) { + for (const [varName, value] of Object.entries(vars)) { + if (env[skillName]?.[varName]) continue; + const bucket = env[skillName] ?? {}; + bucket[varName] = { + ...(current.env[skillName]?.[varName] ?? {}), + value, + configured: true, + }; + env[skillName] = bucket; + } + } + return normalizeSkillsSettings({ ...next, env }); +} + export function redactSttSettingsForWebStorage(stt: AppSettings["stt"]): AppSettings["stt"] { const { allowIncomplete: _allowIncomplete, ...publicStt } = stt; return { @@ -1182,7 +1305,7 @@ export function buildGatewaySettingsSyncPayload( memory: settings.memory, modelFailover: settings.modelFailover, customSettings: syncableCustomSettings(settings.customSettings), - skills: settings.skills, + skills: redactSkillsSettingsSecrets(settings.skills), chatRuntimeControls: settings.chatRuntimeControls, selectedModel: settings.selectedModel ?? null, theme: settings.theme, @@ -1212,6 +1335,12 @@ export function buildGatewaySettingsSyncPayload( if (systemProxyPasswordUpdate !== undefined) { payload.systemProxyPasswordUpdate = systemProxyPasswordUpdate; } + const skillEnvSecretUpdates = options.includeProviderApiKeyUpdates + ? collectSkillEnvSecretUpdates(settings.skills) + : undefined; + if (skillEnvSecretUpdates) { + payload.skillEnvSecretUpdates = skillEnvSecretUpdates; + } return payload; } @@ -1266,6 +1395,13 @@ export function buildGatewaySettingsSyncUpdatePayload( update.system ??= nextPayload.system; update.systemProxyPasswordUpdate = systemProxyPasswordUpdate; } + const skillEnvSecretUpdates = options.includeProviderApiKeyUpdates + ? collectChangedSkillEnvSecretUpdates(prev.skills, next.skills) + : undefined; + if (skillEnvSecretUpdates) { + update.skills ??= nextPayload.skills; + update.skillEnvSecretUpdates = skillEnvSecretUpdates; + } return update; } @@ -1335,7 +1471,19 @@ export function applyGatewaySettingsSyncPayload( chatTranscript: current.customSettings.chatTranscript, fontScale: current.customSettings.fontScale, }, - skills: (source.skills as AppSettings["skills"] | undefined) ?? current.skills, + skills: Object.hasOwn(source, "skills") + ? mergeSyncedSkillsSettings( + current.skills, + source.skills, + normalizeSkillEnvSecretUpdates(source.skillEnvSecretUpdates), + ) + : Object.hasOwn(source, "skillEnvSecretUpdates") + ? mergeSyncedSkillsSettings( + current.skills, + current.skills, + normalizeSkillEnvSecretUpdates(source.skillEnvSecretUpdates), + ) + : current.skills, chatRuntimeControls: Object.hasOwn(source, "chatRuntimeControls") ? normalizeChatRuntimeControls(source.chatRuntimeControls) : current.chatRuntimeControls, diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts index b34020176..f3c3cc5ee 100644 --- a/crates/agent-ui/src/lib/settings/types.ts +++ b/crates/agent-ui/src/lib/settings/types.ts @@ -1,5 +1,6 @@ import type { Locale } from "@liveagent/app/i18n/config"; import type { ThinkingLevel } from "@liveagent/ui/lib/models/modelThinking"; +import type { SkillEnvSettingsMap } from "@liveagent/ui/lib/skills/skillEnv"; import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; export type ProviderId = "codex" | "claude_code" | "gemini" | "xai" | "deepseek"; @@ -36,6 +37,8 @@ export type McpSettings = { export type SkillsSettings = { enabled: boolean; selected: string[]; + /** 技能环境变量配置:技能名 -> 变量名 -> { value?, override? }。 */ + env: SkillEnvSettingsMap; }; export type MemoryOrganizerScope = "all" | "global" | "projects" | "current-project"; diff --git a/crates/agent-ui/src/lib/skills/index.ts b/crates/agent-ui/src/lib/skills/index.ts index 5599eeb2f..dd55eed44 100644 --- a/crates/agent-ui/src/lib/skills/index.ts +++ b/crates/agent-ui/src/lib/skills/index.ts @@ -2,6 +2,7 @@ import { invoke } from "@liveagent/app/shims/tauriCore"; import { sortSkillsForDisplay } from "./builtin"; import type { ClawHubSkillCard } from "./clawHub"; +import { resolveSkillEnvStatus, type SkillEnvSettingsMap } from "./skillEnv"; const SKILLS_DISCOVERY_UPDATED_EVENT = "liveagent:skills-discovery-updated"; @@ -30,6 +31,25 @@ export type SkillSummary = { inlineContent?: string; inlineContentTruncated?: boolean; source?: SkillSourceMetadata | null; + /** 后端探测/声明得到的脚本环境变量依赖(缺失表示旧缓存或无依赖)。 */ + envRequirements?: SkillEnvRequirement[]; +}; + +export type SkillEnvRequirementConfidence = "declared" | "strong" | "weak"; + +/** 技能脚本依赖的单个环境变量(后端探测或 metadata.env 声明)。 */ +export type SkillEnvRequirement = { + name: string; + /** true 时参与「未配置禁止启用」门禁;用户覆盖后以 resolveSkillEnvStatus 结果为准。 */ + required: boolean; + confidence: SkillEnvRequirementConfidence; + provider?: string | null; + description?: string | null; + url?: string | null; + /** 证据文件(相对技能目录),最多 5 个。 */ + sources: string[]; + /** 后端列表时对进程环境变量的现场探测结果。 */ + systemValuePresent: boolean; }; export type SkillSourceMetadata = { @@ -127,6 +147,7 @@ type SystemManageSkillResponse = { builtIn?: boolean; installedAt?: number | null; source?: SkillSourceMetadata | null; + envRequirements?: unknown; }> | null; invalid?: Array<{ path: string; error: string }> | null; installed?: SkillInstallResult[] | null; @@ -153,6 +174,7 @@ type SystemManageSkillResponse = { clawhubDownloadUrl?: string | null; external?: ExternalToolScan[] | null; externalMcp?: ExternalMcpToolScan[] | null; + envProbe?: Array<{ name?: string | null; present?: boolean | null }> | null; }; export type ExternalSkillEntry = { @@ -354,6 +376,52 @@ function isSkillReadmePath(path: string) { return /(?:^|\/)readme\.md$/i.test(path); } +function normalizeSkillEnvRequirements(input: unknown): SkillEnvRequirement[] { + if (!Array.isArray(input)) return []; + const out: SkillEnvRequirement[] = []; + const seen = new Set(); + for (const raw of input) { + if (!raw || typeof raw !== "object") continue; + const item = raw as Record; + const name = typeof item.name === "string" ? item.name.trim() : ""; + if (!name || seen.has(name)) continue; + seen.add(name); + const confidence = + item.confidence === "declared" || item.confidence === "strong" || item.confidence === "weak" + ? item.confidence + : "weak"; + const optionalString = (value: unknown) => + typeof value === "string" && value.trim() ? value : null; + out.push({ + name, + required: item.required === true, + confidence, + provider: optionalString(item.provider), + description: optionalString(item.description), + url: optionalString(item.url), + sources: Array.isArray(item.sources) + ? item.sources.filter((source): source is string => typeof source === "string") + : [], + systemValuePresent: item.systemValuePresent === true, + }); + } + return out; +} + +/** 按名现场探测进程环境变量(走 SkillsManager env_status 动作,双端可用)。 */ +export async function probeSkillEnvNames(names: string[]): Promise> { + const filtered = names.map((name) => name.trim()).filter(Boolean); + if (filtered.length === 0) return {}; + const response = await manageSkill({ action: "env_status", names: filtered }); + const out: Record = {}; + for (const item of response.envProbe ?? []) { + if (typeof item?.name === "string" && item.name) { + out[item.name] = item.present === true; + } + } + return out; +} + export async function ensureBuiltinSkills() { try { return await invoke("system_ensure_builtin_skills"); @@ -379,6 +447,38 @@ export function subscribeSkillsDiscoveryUpdated(listener: () => void) { return () => window.removeEventListener(SKILLS_DISCOVERY_UPDATED_EVENT, listener); } +const SKILL_ENV_CONFIG_REQUEST_EVENT = "liveagent:skill-env-config-request"; + +let pendingSkillEnvConfigTarget: string | null = null; + +/** 聊天引导卡请求打开某技能的环境变量配置。App 壳负责切到 Skills Hub,Hub 页负责打开抽屉。 */ +export function requestSkillEnvConfigNavigation(skillName: string) { + if (typeof window === "undefined") return; + pendingSkillEnvConfigTarget = skillName; + window.dispatchEvent(new CustomEvent(SKILL_ENV_CONFIG_REQUEST_EVENT, { detail: { skillName } })); +} + +/** Skills Hub 页取走待打开的技能名(一次性;覆盖事件发出时页面尚未挂载的冷启动路径)。 */ +export function consumeSkillEnvConfigTarget(): string | null { + const target = pendingSkillEnvConfigTarget; + pendingSkillEnvConfigTarget = null; + return target; +} + +export function subscribeSkillEnvConfigRequested(listener: (skillName: string) => void) { + if (typeof window === "undefined") { + return () => undefined; + } + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ skillName?: unknown }>).detail; + if (typeof detail?.skillName === "string" && detail.skillName) { + listener(detail.skillName); + } + }; + window.addEventListener(SKILL_ENV_CONFIG_REQUEST_EVENT, handler); + return () => window.removeEventListener(SKILL_ENV_CONFIG_REQUEST_EVENT, handler); +} + export function invalidateSkillsDiscoveryCache() { cachedDiscovery = null; inFlightDiscovery = null; @@ -472,6 +572,7 @@ async function managedSkillListToDiscovery( ? raw.installedAt : null, source: normalizeSkillSourceMetadata(raw.source), + envRequirements: normalizeSkillEnvRequirements(raw.envRequirements), }); } // README 回退型 skill 的富化各需两次串行往返;数量多时串行等待主导加载耗时, @@ -611,10 +712,23 @@ export async function cancelSkillInstallJob(jobId: string): Promise(); + if (params.envSettings) { + for (const skill of selected) { + const status = resolveSkillEnvStatus(skill, params.envSettings); + if (!status.satisfied) { + missingBySkill.set(skill.name, status.missingRequired); + } + } + } + return [ "The following Skills are enabled by the user. Skill files are exposed to file tools through skill:///... paths.", "", @@ -631,15 +745,22 @@ export function buildSkillsSystemPrompt(params: { "- Do not guess a Skill's exact instructions or script paths before reading the Skill file.", "- Relative paths inside a Skill (scripts/, references/, assets/, and so on) are resolved relative to baseDir.", "- If a Skill contains the {baseDir} placeholder, interpret it as the baseDir value in the metadata below (relative to the Skills root directory).", + ...(missingBySkill.size > 0 + ? [ + '- Skills marked "status: unavailable" are missing required environment variables. Do not read them with SkillsManager and do not follow their workflows yet. Tell the user which variables are missing and wait for the user to configure them in the Skill detail page. Never ask the user to paste secret values into the chat.', + ] + : []), "", "Skills:", - ...selected.map((s) => - [ + ...selected.map((s) => { + const missing = missingBySkill.get(s.name); + return [ `- name: ${s.name}`, ` description: ${s.description}`, ` skillFile: ${s.skillFile}`, ` baseDir: ${s.baseDir}`, - s.inlineContent !== undefined + missing ? ` status: unavailable (missing env: ${missing.join(", ")})` : "", + s.inlineContent !== undefined && !missing ? [ ` loadedFrom: README.md without metadata`, ` truncated: ${s.inlineContentTruncated ? "true" : "false"}`, @@ -649,8 +770,8 @@ export function buildSkillsSystemPrompt(params: { "", ].join("\n") : "", - ].join("\n"), - ), + ].join("\n"); + }), ].join("\n"); } diff --git a/crates/agent-ui/src/lib/skills/skillEnv.ts b/crates/agent-ui/src/lib/skills/skillEnv.ts new file mode 100644 index 000000000..9104a2c76 --- /dev/null +++ b/crates/agent-ui/src/lib/skills/skillEnv.ts @@ -0,0 +1,263 @@ +import type { SkillEnvRequirement, SkillSummary } from "./index"; + +/** + * 技能环境变量的用户配置与状态归并。 + * + * 后端探测(SkillSummary.envRequirements)只作**建议**——扫描脚本判断依赖 + * 不够准确,不能当权威。启用门禁只认两类明确来源:metadata.env 声明的必填 + * 项,以及用户手动标记/采纳的变量。满足判定:用户填写的值优先,其次系统 + * 环境变量现场探测;明确必需项两者皆无时技能进入「待配置」,前端禁止启用、 + * 运行时拦截读取。每个技能的详情抽屉恒显配置入口,与探测结果无关。 + */ + +/** 单个技能环境变量的用户配置。 */ +export type SkillEnvVarConfig = { + /** 用户填写的值;空串等同未填写。 */ + value?: string; + /** + * 同步/存储脱敏后的"值已配置"标记:值本体只存在桌面端,WebUI 与 + * 浏览器缓存里的副本靠它保留已配置状态(对齐 provider apiKeyConfigured)。 + */ + configured?: boolean; + /** + * 对探测结论的覆盖: + * - "required":弱信号升级为必需,或手动添加的变量; + * - "ignored":误报豁免,不再参与门禁。 + */ + override?: "required" | "ignored"; +}; + +/** 技能名 -> 变量名 -> 配置。只保存有内容的条目。 */ +export type SkillEnvSettingsMap = Record>; + +const SKILL_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; + +export function isValidSkillEnvVarName(name: string) { + return SKILL_ENV_NAME_PATTERN.test(name); +} + +function normalizeSkillEnvVarConfig(input: unknown): SkillEnvVarConfig | null { + if (!input || typeof input !== "object") return null; + const obj = input as Record; + const config: SkillEnvVarConfig = {}; + if (typeof obj.value === "string" && obj.value.trim()) { + config.value = obj.value; + } + if (obj.configured === true) { + config.configured = true; + } + if (obj.override === "required" || obj.override === "ignored") { + config.override = obj.override; + } + return config.value !== undefined || + config.configured !== undefined || + config.override !== undefined + ? config + : null; +} + +export function normalizeSkillEnvSettings(input: unknown): SkillEnvSettingsMap { + if (!input || typeof input !== "object") return {}; + const out: SkillEnvSettingsMap = {}; + for (const [skillName, rawVars] of Object.entries(input as Record)) { + if (!skillName.trim() || !rawVars || typeof rawVars !== "object") continue; + const vars: Record = {}; + for (const [varName, rawConfig] of Object.entries(rawVars as Record)) { + if (!isValidSkillEnvVarName(varName)) continue; + const config = normalizeSkillEnvVarConfig(rawConfig); + if (config) vars[varName] = config; + } + if (Object.keys(vars).length > 0) { + out[skillName] = vars; + } + } + return out; +} + +/** 归并后的单变量状态。 */ +export type ResolvedSkillEnvRequirement = { + name: string; + /** 探测/声明置信度;"user" 表示用户手动添加。 */ + confidence: "declared" | "strong" | "weak" | "user"; + /** 应用覆盖后的最终必需性(参与启用门禁)。 */ + effectiveRequired: boolean; + /** 满足来源:user=用户填写值,system=系统环境变量,missing=未满足。 */ + state: "user" | "system" | "missing"; + /** 被用户标记为误报豁免。 */ + ignored: boolean; + provider: string | null; + description: string | null; + url: string | null; + sources: string[]; +}; + +export type SkillEnvStatus = { + requirements: ResolvedSkillEnvRequirement[]; + /** 未满足的必需变量名。 */ + missingRequired: string[]; + /** 必需项全部满足(无必需项时为 true)。启用门禁与运行时拦截都以此为准。 */ + satisfied: boolean; +}; + +const EMPTY_SKILL_ENV_STATUS: SkillEnvStatus = { + requirements: [], + missingRequired: [], + satisfied: true, +}; + +/** + * 合并探测结果与用户配置,得到技能的环境变量最终状态。 + * + * `probeOverrides` 用于携带 `env_status` 动作的现场探测结果(手动添加的变量 + * 不在后端列表探测范围内,详情页按需补测后传入)。 + */ +export function resolveSkillEnvStatus( + skill: Pick, + envSettings: SkillEnvSettingsMap | undefined, + probeOverrides?: Record, +): SkillEnvStatus { + const requirements = skill.envRequirements ?? []; + const config = envSettings?.[skill.name] ?? {}; + if (requirements.length === 0 && Object.keys(config).length === 0) { + return EMPTY_SKILL_ENV_STATUS; + } + + const resolved: ResolvedSkillEnvRequirement[] = []; + const detectedNames = new Set(); + + const resolveState = ( + varConfig: SkillEnvVarConfig | undefined, + systemValuePresent: boolean, + ): ResolvedSkillEnvRequirement["state"] => { + // configured=true 表示值已存在桌面端(WebUI 侧的脱敏副本没有值本体)。 + if ( + (typeof varConfig?.value === "string" && varConfig.value.trim()) || + varConfig?.configured === true + ) { + return "user"; + } + if (systemValuePresent) return "system"; + return "missing"; + }; + + for (const requirement of requirements) { + detectedNames.add(requirement.name); + const varConfig = config[requirement.name]; + const ignored = varConfig?.override === "ignored"; + // 门禁只认明确来源:声明必填 + 用户标记必需。探测(strong/weak)仅建议。 + const effectiveRequired = ignored + ? false + : varConfig?.override === "required" + ? true + : requirement.confidence === "declared" && requirement.required; + const systemValuePresent = probeOverrides?.[requirement.name] ?? requirement.systemValuePresent; + resolved.push({ + name: requirement.name, + confidence: requirement.confidence, + effectiveRequired, + state: resolveState(varConfig, systemValuePresent), + ignored, + provider: requirement.provider ?? null, + description: requirement.description ?? null, + url: requirement.url ?? null, + sources: requirement.sources, + }); + } + + // 用户手动添加的变量(配置里有、探测里没有)。标记忽略的不展示。 + for (const [name, varConfig] of Object.entries(config)) { + if (detectedNames.has(name) || varConfig.override === "ignored") continue; + resolved.push({ + name, + confidence: "user", + effectiveRequired: varConfig.override === "required", + state: resolveState(varConfig, probeOverrides?.[name] ?? false), + ignored: false, + provider: null, + description: null, + url: null, + sources: [], + }); + } + + const missingRequired = resolved + .filter((entry) => entry.effectiveRequired && entry.state === "missing") + .map((entry) => entry.name); + + return { + requirements: resolved, + missingRequired, + satisfied: missingRequired.length === 0, + }; +} + +/** 技能是否满足启用门禁(必需环境变量全部有值)。 */ +export function isSkillEnvSatisfied( + skill: Pick, + envSettings: SkillEnvSettingsMap | undefined, +) { + return resolveSkillEnvStatus(skill, envSettings).satisfied; +} + +/** + * 收集应注入 shell 子进程的环境变量(仅用户填写的值;系统环境变量子进程 + * 本来就继承,不重复传递)。调用方负责只传入本会话生效的技能。 + * 同名冲突按技能名字典序处理,后者覆盖,保证结果确定。 + */ +export function collectSkillEnvInjection( + skills: ReadonlyArray>, + envSettings: SkillEnvSettingsMap | undefined, +): Record { + if (!envSettings) return {}; + const out: Record = {}; + const names = skills.map((skill) => skill.name).sort((a, b) => a.localeCompare(b)); + for (const skillName of names) { + const vars = envSettings[skillName]; + if (!vars) continue; + for (const [varName, config] of Object.entries(vars)) { + if (!isValidSkillEnvVarName(varName)) continue; + if (typeof config.value === "string" && config.value.trim()) { + out[varName] = config.value; + } + } + } + return out; +} + +/** envRequirements 的稳定摘要,用于技能发现签名(变化触发 UI 刷新)。 */ +export function skillEnvRequirementsSignature( + requirements: SkillEnvRequirement[] | undefined, +): string { + if (!requirements || requirements.length === 0) return ""; + return requirements + .map( + (entry) => + `${entry.name}:${entry.required ? "1" : "0"}:${entry.confidence}:${ + entry.systemValuePresent ? "1" : "0" + }`, + ) + .join(","); +} + +/** + * 解析手动添加输入:每行一个 `NAME` 或 `NAME=值`(容忍 `export ` 前缀、 + * `#` 注释行、成对引号包裹的值),返回合法条目,按出现顺序去重。 + * 支持一次粘贴多行(.env 风格)批量添加。 + */ +export function parseSkillEnvAddEntries(input: string): Array<{ name: string; value?: string }> { + const out: Array<{ name: string; value?: string }> = []; + const seen = new Set(); + for (const rawLine of input.split(/\r?\n/)) { + const line = rawLine.trim().replace(/^export\s+/i, ""); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + const name = (eq >= 0 ? line.slice(0, eq) : line).trim(); + if (!isValidSkillEnvVarName(name) || seen.has(name)) continue; + let value = eq >= 0 ? line.slice(eq + 1).trim() : ""; + const quoted = /^(["'])(.*)\1$/.exec(value); + if (quoted) value = quoted[2]; + seen.add(name); + out.push(value ? { name, value } : { name }); + } + return out; +} diff --git a/crates/agent-ui/src/pages/skills-hub/InstalledSkillCard.tsx b/crates/agent-ui/src/pages/skills-hub/InstalledSkillCard.tsx index 7f5526992..bdd5d8706 100644 --- a/crates/agent-ui/src/pages/skills-hub/InstalledSkillCard.tsx +++ b/crates/agent-ui/src/pages/skills-hub/InstalledSkillCard.tsx @@ -161,6 +161,8 @@ type InstalledSkillCardProps = { deleting: boolean; deleteDisabled: boolean; searchQuery: string; + /** 必需环境变量是否全部满足;false 时禁止启用并显示「待配置」徽标。 */ + envSatisfied: boolean; onToggle: (name: string, on: boolean) => void; onEnterBulkMode: (name: string) => void; onToggleBulkSelection: (name: string) => void; @@ -187,6 +189,7 @@ export const InstalledSkillCard = memo(function InstalledSkillCard(props: Instal deleting, deleteDisabled, searchQuery, + envSatisfied, onToggle, onEnterBulkMode, onToggleBulkSelection, @@ -197,6 +200,8 @@ export const InstalledSkillCard = memo(function InstalledSkillCard(props: Instal } = props; const { t } = useLocale(); const effectivelyEnabled = skillsEnabled && checked; + // 未启用且必需环境变量未满足:开关置灰,点击改为打开详情抽屉引导配置。 + const envGated = !alwaysEnabled && !envSatisfied && !checked; const cardIdentity = useMemo( () => (alwaysEnabled ? null : getInstalledSkillCardIdentity(skill.name, primaryCategory)), [alwaysEnabled, primaryCategory, skill.name], @@ -249,6 +254,20 @@ export const InstalledSkillCard = memo(function InstalledSkillCard(props: Instal ) ) : alwaysEnabled ? null : ( <> + - onToggle(skill.name, nextChecked)} - /> + + onToggle(skill.name, nextChecked)} + /> + {envGated && skillsEnabled ? ( + + ); +} + +function InstalledSkillEnvSection(props: { + skill: SkillSummary; + envSettings: SkillEnvSettingsMap; + onEnvVarChange: (skillName: string, varName: string, config: SkillEnvVarConfig | null) => void; +}) { + const { skill, envSettings, onEnvVarChange } = props; + const { t } = useLocale(); + const [probeResults, setProbeResults] = useState>({}); + const [probing, setProbing] = useState(false); + const [othersOpen, setOthersOpen] = useState(false); + const [addDraft, setAddDraft] = useState(""); + const probeRequestedRef = useRef(false); + + const status = useMemo( + () => resolveSkillEnvStatus(skill, envSettings, probeResults), + [skill, envSettings, probeResults], + ); + const skillConfig = envSettings[skill.name]; + + const refreshProbe = useCallback(async () => { + const names = status.requirements.map((entry) => entry.name); + if (names.length === 0) return; + setProbing(true); + try { + const results = await probeSkillEnvNames(names); + setProbeResults((prev) => ({ ...prev, ...results })); + } catch { + // 探测失败保持上一次结果,不打断配置流程。 + } finally { + setProbing(false); + } + }, [status.requirements]); + + // 打开抽屉时探测一次系统环境(后端列表探测可能已过期)。 + useEffect(() => { + if (probeRequestedRef.current) return; + probeRequestedRef.current = true; + void refreshProbe(); + }, [refreshProbe]); + + // 主列表 = 明确条目(声明、用户添加/采纳、已填值);纯探测结果只进建议组。 + const isSuggestionRow = (entry: ResolvedSkillEnvRequirement) => + (entry.confidence === "strong" || entry.confidence === "weak") && + entry.state !== "user" && + !entry.effectiveRequired; + const mainRows = status.requirements.filter((entry) => !isSuggestionRow(entry)); + const suggestionRows = status.requirements.filter(isSuggestionRow); + + const configFor = (name: string) => skillConfig?.[name]; + const withValue = (name: string, patch: Partial): SkillEnvVarConfig | null => { + const existing = configFor(name); + const next: SkillEnvVarConfig = {}; + if (typeof existing?.value === "string" && existing.value.trim()) next.value = existing.value; + if (existing?.configured === true) next.configured = true; + if (existing?.override) next.override = existing.override; + if ("value" in patch) { + if (patch.value) { + next.value = patch.value; + next.configured = true; + } else { + // 显式清除:值与 configured 标记一起移除(同步侧据此清掉桌面端已存值)。 + delete next.value; + delete next.configured; + } + } + if ("override" in patch) { + if (patch.override) next.override = patch.override; + else delete next.override; + } + return next.value !== undefined || next.configured !== undefined || next.override !== undefined + ? next + : null; + }; + + const commitValue = (name: string, value: string) => { + onEnvVarChange(skill.name, name, withValue(name, { value })); + }; + const clearValue = (name: string) => { + onEnvVarChange(skill.name, name, withValue(name, { value: undefined })); + }; + const markIgnored = (entry: ResolvedSkillEnvRequirement) => { + onEnvVarChange(skill.name, entry.name, withValue(entry.name, { override: "ignored" })); + }; + const removeEntry = (entry: ResolvedSkillEnvRequirement) => { + // 用户添加/采纳的条目"移除"即整条删除,探测条目退回建议组。 + onEnvVarChange(skill.name, entry.name, null); + }; + const restoreIgnored = (name: string) => { + onEnvVarChange(skill.name, name, withValue(name, { override: undefined })); + }; + const adoptEntry = (name: string) => { + onEnvVarChange(skill.name, name, withValue(name, { override: "required" })); + }; + const addFromText = (text: string) => { + const entries = parseSkillEnvAddEntries(text); + if (entries.length === 0) return false; + for (const entry of entries) { + onEnvVarChange( + skill.name, + entry.name, + withValue(entry.name, { + override: "required", + ...(entry.value ? { value: entry.value } : {}), + }), + ); + } + return true; + }; + + return ( +
+
+

+ {t("settings.skillsEnvSectionTitle")} +

+ {status.requirements.length > 0 ? ( + + ) : null} +
+ + {!status.satisfied ? ( +
+ + + {t("settings.skillsEnvGateHint").replace( + "{count}", + String(status.missingRequired.length), + )} + +
+ ) : null} + + {mainRows.length > 0 ? ( +
+ {mainRows.map((entry) => ( +
+
+ + {entry.name} + + {entry.provider ? ( + + {entry.provider} + + ) : null} + + + +
+ {entry.description ? ( +

+ {entry.description} +

+ ) : null} +
+ commitValue(entry.name, value)} + /> +
+
+ {entry.url ? ( + + {t("settings.skillsEnvApplyUrl")} + + ) : null} + {entry.sources.length > 0 ? ( + + {t("settings.skillsEnvSources")} {entry.sources.join(", ")} + + ) : null} + + {entry.state === "user" ? ( + clearValue(entry.name)} + /> + ) : null} + + entry.confidence === "declared" ? markIgnored(entry) : removeEntry(entry) + } + /> + +
+
+ ))} +
+ ) : null} + + {suggestionRows.length > 0 ? ( +
+ + {othersOpen ? ( +
+ {suggestionRows.map((entry) => ( +
+ + {entry.name} + + {entry.ignored ? ( + + {t("settings.skillsEnvIgnoredBadge")} + + ) : entry.state !== "missing" ? ( + + ) : null} + + {entry.ignored ? ( + restoreIgnored(entry.name)} + /> + ) : ( + adoptEntry(entry.name)} + /> + )} + +
+ ))} +
+ ) : null} +
+ ) : null} + + {mainRows.length === 0 && suggestionRows.length === 0 ? ( +

+ {t("settings.skillsEnvEmptyHint")} +

+ ) : null} + +
+ setAddDraft(event.target.value)} + onPaste={(event) => { + // 粘贴 NAME=值 或多行 .env 内容时直接批量导入。 + const text = event.clipboardData.getData("text"); + if (!/[\r\n=]/.test(text)) return; + event.preventDefault(); + if (addFromText(text)) setAddDraft(""); + else setAddDraft(text.trim()); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (addFromText(addDraft)) setAddDraft(""); + } + }} + /> + +
+
+ ); +} diff --git a/crates/agent-ui/src/pages/skills-hub/SkillsHubPage.tsx b/crates/agent-ui/src/pages/skills-hub/SkillsHubPage.tsx index 8e7f1e7cb..ceabf83cc 100644 --- a/crates/agent-ui/src/pages/skills-hub/SkillsHubPage.tsx +++ b/crates/agent-ui/src/pages/skills-hub/SkillsHubPage.tsx @@ -1,6 +1,7 @@ import { type AppSettings, removeWorkspaceResourceReferences, + updateSkillEnvVar, updateSkills, } from "@liveagent/app/lib/settings"; import { GlassPanel, HubHeader } from "@liveagent/ui/components/hub/HubChrome"; @@ -44,6 +45,7 @@ import { } from "@liveagent/ui/lib/skills/clawHub"; import type { ClawHubCategorySlug } from "@liveagent/ui/lib/skills/clawHubCategories"; import { + consumeSkillEnvConfigTarget, discoverSkills, type ExternalSkillEntry, type ExternalToolScan, @@ -58,12 +60,14 @@ import { type SkillSummary, scanExternalSkills, startSkillInstallJob, + subscribeSkillEnvConfigRequested, } from "@liveagent/ui/lib/skills/index"; import { type InstalledSkillSort, isInstalledSkillSort, sortInstalledSkillItems, } from "@liveagent/ui/lib/skills/installedSort"; +import { isSkillEnvSatisfied, type SkillEnvVarConfig } from "@liveagent/ui/lib/skills/skillEnv"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { reconcileExternalToolScans } from "./externalSkillScanState"; import { InstalledSkillCard } from "./InstalledSkillCard"; @@ -217,6 +221,22 @@ export function SkillsHubPage(props: SkillsHubPageProps) { const discoverySignatureRef = useRef(null); const skillsSnapshotRef = useRef(initialSkills ?? []); + // 聊天引导卡请求配置某技能:等技能列表就绪后打开其详情抽屉(运行要求区块 + // 就在描述下方)。挂载前发出的请求通过一次性 pending 目标兜底。 + const [pendingEnvConfigSkill, setPendingEnvConfigSkill] = useState(() => + consumeSkillEnvConfigTarget(), + ); + useEffect(() => subscribeSkillEnvConfigRequested(setPendingEnvConfigSkill), []); + useEffect(() => { + if (!pendingEnvConfigSkill) return; + const skill = skills.find((item) => item.name === pendingEnvConfigSkill); + if (skill) { + setView("installed"); + setPreviewInstalledSkill(skill); + setPendingEnvConfigSkill(null); + } + }, [pendingEnvConfigSkill, skills]); + const dismissScanFeedback = useCallback(() => { if (scanFeedbackTimerRef.current !== null) { window.clearTimeout(scanFeedbackTimerRef.current); @@ -364,6 +384,15 @@ export function SkillsHubPage(props: SkillsHubPageProps) { () => new Set(mergeAlwaysEnabledSkillNames(settings.skills.selected)), [settings.skills.selected], ); + // 环境变量门禁:必需项未满足的技能禁止启用。satisfied=false 的技能开关 + // 被拦截并引导到详情抽屉的「运行要求」区块补齐配置。 + const skillEnvSatisfied = useMemo(() => { + const map = new Map(); + for (const skill of skills) { + map.set(skill.name, isSkillEnvSatisfied(skill, settings.skills.env)); + } + return map; + }, [skills, settings.skills.env]); // React 19 的 initialValue 让 Hub 外壳先独立提交;大量卡片在可中断的后台 // render 中准备,全部完成后再原子替换加载态,避免页面切换被首屏列表挂载阻塞。 const deferredSkills = useDeferredValue(skills, EMPTY_SKILLS); @@ -1000,6 +1029,12 @@ export function SkillsHubPage(props: SkillsHubPageProps) { function toggleSkill(name: string, on: boolean) { if (isAlwaysEnabledSkillName(name)) return; + if (on && skillEnvSatisfied.get(name) === false) { + // 缺少必需环境变量时不启用,改为打开详情抽屉引导配置。 + const skill = skills.find((item) => item.name === name); + if (skill) setPreviewInstalledSkill(skill); + return; + } const next = new Set(settings.skills.selected); if (on) next.add(name); else next.delete(name); @@ -1101,7 +1136,11 @@ export function SkillsHubPage(props: SkillsHubPageProps) { // 传给 setSettings 的 updater 必须是纯函数(StrictMode 会双调用)。 const applyBulkEnableState = useCallback( (target: boolean) => { - const names = [...bulkSelection].filter((name) => !isAlwaysEnabledSkillName(name)); + // 批量启用跳过环境变量门禁未满足的技能(按钮计数已同步扣除)。 + const names = [...bulkSelection].filter( + (name) => + !isAlwaysEnabledSkillName(name) && (!target || skillEnvSatisfied.get(name) !== false), + ); if (names.length === 0) return; const before = settings.skills.selected; @@ -1139,6 +1178,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { requestInstalledSkillFlip, setSettings, settings.skills.selected, + skillEnvSatisfied, ], ); @@ -1295,10 +1335,11 @@ export function SkillsHubPage(props: SkillsHubPageProps) { let count = 0; for (const name of bulkSelection) { if (isAlwaysEnabledSkillName(name)) continue; + if (skillEnvSatisfied.get(name) === false) continue; if (!selected.has(name)) count += 1; } return count; - }, [bulkSelection, selected]); + }, [bulkSelection, selected, skillEnvSatisfied]); const bulkDisableChangeCount = useMemo(() => { let count = 0; for (const name of bulkSelection) { @@ -1327,6 +1368,13 @@ export function SkillsHubPage(props: SkillsHubPageProps) { setPreviewInstalledSkill(skill); } + const handleEnvVarChange = useCallback( + (skillName: string, varName: string, config: SkillEnvVarConfig | null) => { + setSettings((prev) => updateSkillEnvVar(prev, skillName, varName, config)); + }, + [setSettings], + ); + // memo 卡片的回调走 latest-ref(先例 file-tree):引用恒定使 memo 不失效, // 实现经 ref 每渲染更新到最新闭包。 const sortedInstalledNames = useMemo( @@ -1752,6 +1800,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { deleting={deletingSkillName === skill.name} deleteDisabled={deletingSkillName !== null} searchQuery={deferredFilter} + envSatisfied={skillEnvSatisfied.get(skill.name) !== false} onToggle={handleCardToggle} onEnterBulkMode={enterBulkMode} onToggleBulkSelection={toggleBulkSelectionName} @@ -1840,6 +1889,8 @@ export function SkillsHubPage(props: SkillsHubPageProps) { selected.has(previewInstalledSkill.name)) } skillsEnabled={skillsEnabled} + envSettings={settings.skills.env} + onEnvVarChange={handleEnvVarChange} onClose={() => setPreviewInstalledSkill(null)} /> diff --git a/crates/agent-ui/src/pages/skills-hub/skillScanSummary.ts b/crates/agent-ui/src/pages/skills-hub/skillScanSummary.ts index a9f1c7a0f..11bab103d 100644 --- a/crates/agent-ui/src/pages/skills-hub/skillScanSummary.ts +++ b/crates/agent-ui/src/pages/skills-hub/skillScanSummary.ts @@ -1,4 +1,5 @@ import type { SkillSummary } from "@liveagent/ui/lib/skills/index"; +import { skillEnvRequirementsSignature } from "@liveagent/ui/lib/skills/skillEnv"; export type SkillScanSummary = { total: number; @@ -19,6 +20,7 @@ export function buildSkillEntrySignature(skill: SkillSummary) { skill.source?.slug ?? "", skill.installedAt ?? "", skill.source?.version ?? "", + skillEnvRequirementsSignature(skill.envRequirements), ].join("\0"); } diff --git a/package.json b/package.json index 04113a9b9..56737c06c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ }, "devDependencies": { "@babel/parser": "7.29.8", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0", "esbuild": "0.28.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41d2c995c..f53a070b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: '@babel/parser': specifier: 7.29.8 version: 7.29.8 + '@svgr/core': + specifier: ^8.1.0 + version: 8.1.0(typescript@7.0.2) + '@svgr/plugin-jsx': + specifier: ^8.1.0 + version: 8.1.0(@svgr/core@8.1.0(typescript@7.0.2)) esbuild: specifier: 0.28.2 version: 0.28.2