From e1eda2db55f009bb4b73c78aa813bbdbb223f78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Fri, 15 May 2026 21:47:20 +0800 Subject: [PATCH] feat(models): import external client configs Add a model client import flow that scans local Codex, Claude Code, Gemini CLI, and common environment variable configurations without reading or copying OAuth tokens. The new backend command returns safe import candidates with provider metadata, model IDs, and API key environment-variable references. Tauri and Web/dev-api both implement the scanner, and Web mode keeps the scan local even when a remote instance is active. The Models page now offers an import wizard that lets users select importable candidates, adds providers without overwriting existing keys, preserves secrets as ${ENV_VAR} references, and leaves OAuth-only Codex entries as guidance rather than direct OpenClaw imports. ## Verification - node --check src/pages/models.js - node --check src/lib/tauri-api.js - node --check src/locales/modules/models.js - node --check scripts/dev-api.js - cargo fmt --check - cargo check - npm run build --- scripts/dev-api.js | 198 +++++++- src-tauri/src/commands/config.rs | 362 +++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/tauri-api.js | 1 + src/locales/modules/models.js | 15 + src/pages/assistant.js | 748 ++++++++++++++++++++++++++++++- src/pages/dashboard.js | 9 +- src/pages/models.js | 117 +++++ 8 files changed, 1425 insertions(+), 26 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index a43da5f..a4ce575 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3264,7 +3264,7 @@ const ALWAYS_LOCAL = new Set([ 'docker_cluster_overview', 'auth_check', 'auth_login', 'auth_logout', 'read_panel_config', 'write_panel_config', - 'get_deploy_mode', + 'get_deploy_mode', 'scan_model_client_configs', 'assistant_exec', 'assistant_read_file', 'assistant_write_file', 'assistant_list_dir', 'assistant_system_info', 'assistant_list_processes', 'assistant_check_port', 'assistant_web_search', 'assistant_fetch_url', @@ -3349,6 +3349,198 @@ function resolveModelApiKey(apiKey) { throw new Error(`API Key 引用了环境变量 ${key},但未在 openclaw.json env、~/.openclaw/.env 或当前进程环境中找到`) } +function _homePath(...parts) { + return path.join(homedir(), ...parts) +} + +function _stripConfigValue(raw) { + let out = '' + let quote = '' + for (const ch of String(raw || '').trim()) { + if (ch === '"' || ch === "'") { + quote = quote === ch ? '' : (!quote ? ch : quote) + out += ch + continue + } + if (ch === '#' && !quote) break + out += ch + } + let value = out.trim().replace(/,+$/, '').trim() + if (value.length >= 2) { + const first = value[0] + const last = value[value.length - 1] + if ((first === '"' && last === '"') || (first === "'" && last === "'")) value = value.slice(1, -1) + } + return value +} + +function _parseSimpleConfigBlocks(raw) { + const blocks = { '': {} } + let current = '' + for (const line of String(raw || '').split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + current = trimmed.slice(1, -1).trim() + if (!blocks[current]) blocks[current] = {} + continue + } + const eq = trimmed.indexOf('=') + if (eq < 0) continue + blocks[current][trimmed.slice(0, eq).trim()] = _stripConfigValue(trimmed.slice(eq + 1)) + } + return blocks +} + +function _firstEnvRef(keys) { + for (const key of keys) { + if (process.env[key] && String(process.env[key]).trim()) return [`\${${key}}`, 'found'] + } + return keys.length ? [`\${${keys[0]}}`, 'missing'] : ['', 'none'] +} + +function _findJsonString(value, keys, depth = 0) { + if (!value || depth > 5) return '' + if (Array.isArray(value)) { + for (const item of value) { + const found = _findJsonString(item, keys, depth + 1) + if (found) return found + } + return '' + } + if (typeof value === 'object') { + for (const key of keys) { + const v = value[key] + if (typeof v === 'string' && v.trim()) return v.trim() + } + for (const item of Object.values(value)) { + const found = _findJsonString(item, keys, depth + 1) + if (found) return found + } + } + return '' +} + +function _pushClientCandidate(out, data) { + out.push({ + id: data.id, + source: data.source, + sourcePath: data.sourcePath || '', + providerKey: data.providerKey, + displayName: data.displayName, + baseUrl: data.baseUrl || '', + api: data.api || 'openai-completions', + apiKey: data.apiKey || '', + apiKeyStatus: data.apiKeyStatus || 'none', + models: Array.isArray(data.models) ? data.models.filter(Boolean) : [], + importable: data.importable !== false, + authHint: data.authHint || '', + warning: data.warning || '', + }) +} + +function _scanJsonClientFile(out, data) { + const filePath = _homePath(...data.parts) + if (!fs.existsSync(filePath)) return + let model = data.defaultModel + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) + model = _findJsonString(parsed, ['model', 'defaultModel', 'modelName']) || model + } catch {} + const [apiKey, apiKeyStatus] = _firstEnvRef(data.envKeys) + _pushClientCandidate(out, { + ...data, + sourcePath: filePath, + apiKey, + apiKeyStatus, + models: [model], + warning: apiKeyStatus === 'missing' ? '未在当前进程环境中检测到对应 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。' : '', + }) +} + +function scanModelClientConfigs() { + const candidates = [] + const codexPath = _homePath('.codex', 'config.toml') + if (fs.existsSync(codexPath)) { + try { + const blocks = _parseSimpleConfigBlocks(fs.readFileSync(codexPath, 'utf8')) + const root = blocks[''] || {} + const providerId = root.model_provider || 'openai' + const section = blocks[`model_providers.${providerId}`] || {} + const model = root.model || 'gpt-5.1-codex-mini' + const baseUrl = section.base_url || (providerId.includes('codex') ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com/v1') + const explicitEnvKey = isValidEnvKey(section.env_key) ? section.env_key : '' + const envKey = explicitEnvKey || (providerId === 'openai' ? 'OPENAI_API_KEY' : '') + const isExternalCodex = providerId.includes('codex') || baseUrl.includes('chatgpt.com/backend-api/codex') + const api = isExternalCodex ? 'openai-codex-responses' : (String(section.wire_api || '').includes('responses') ? 'openai-responses' : 'openai-completions') + const apiKeyStatus = envKey ? (process.env[envKey] && String(process.env[envKey]).trim() ? 'found' : 'missing') : 'none' + const warning = isExternalCodex + ? 'ChatGPT/Codex OAuth 令牌不会导入到 OpenClaw。请优先使用 Hermes 的 openai-codex 登录。' + : (apiKeyStatus === 'none' + ? 'Codex 配置没有声明可安全引用的 env_key,无法自动导入 API Key。请在 Codex 配置中添加 env_key,或在 OpenClaw 中手动配置服务商密钥。' + : (apiKeyStatus === 'missing' ? '未在当前进程环境中检测到 Codex 配置引用的 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。' : '')) + _pushClientCandidate(candidates, { + id: 'codex-cli', + source: 'Codex CLI', + sourcePath: codexPath, + providerKey: providerId === 'openai' ? 'codex-openai' : `codex-${providerId}`, + displayName: `Codex CLI / ${providerId}`, + baseUrl, + api, + apiKey: envKey ? `\${${envKey}}` : '', + apiKeyStatus, + models: [model], + importable: !isExternalCodex && apiKeyStatus !== 'none', + authHint: isExternalCodex ? 'hermes auth login openai-codex' : '', + warning, + }) + } catch {} + } + _scanJsonClientFile(candidates, { + id: 'claude-code', + source: 'Claude Code', + parts: ['.claude', 'settings.json'], + providerKey: 'anthropic', + displayName: 'Anthropic / Claude Code', + baseUrl: 'https://api.anthropic.com/v1', + api: 'anthropic-messages', + envKeys: ['ANTHROPIC_API_KEY', 'ANTHROPIC_TOKEN'], + defaultModel: 'claude-sonnet-4-5-20250514', + }) + _scanJsonClientFile(candidates, { + id: 'gemini-cli', + source: 'Gemini CLI', + parts: ['.gemini', 'settings.json'], + providerKey: 'google', + displayName: 'Google Gemini CLI', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + api: 'google-generative-ai', + envKeys: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], + defaultModel: 'gemini-2.5-pro', + }) + for (const item of [ + ['OPENAI_API_KEY', 'openai-env', 'OpenAI 环境变量', process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', 'openai-completions', process.env.OPENAI_MODEL || 'gpt-4o'], + ['ANTHROPIC_API_KEY', 'anthropic-env', 'Anthropic 环境变量', 'https://api.anthropic.com/v1', 'anthropic-messages', process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-5-20250514'], + ['GEMINI_API_KEY', 'gemini-env', 'Gemini 环境变量', 'https://generativelanguage.googleapis.com/v1beta', 'google-generative-ai', process.env.GEMINI_MODEL || 'gemini-2.5-pro'], + ]) { + const [envKey, providerKey, displayName, baseUrl, api, model] = item + if (!process.env[envKey] || !String(process.env[envKey]).trim()) continue + _pushClientCandidate(candidates, { + id: providerKey, + source: 'Environment', + sourcePath: envKey, + providerKey, + displayName, + baseUrl, + api, + apiKey: `\${${envKey}}`, + apiKeyStatus: 'found', + models: [model], + }) + } + return { candidates } +} + // 从 SSE 流文本中累积 OpenAI 风格的 delta.content / delta.reasoning_content // 同时兼容 Anthropic streaming (content_block_delta) // 格式示例: @@ -5934,6 +6126,10 @@ const handlers = { throw new Error('ZIP 导出仅在 Tauri 桌面应用中可用') }, + scan_model_client_configs() { + return scanModelClientConfigs() + }, + // 备份管理 list_backups() { if (!fs.existsSync(BACKUPS_DIR)) return [] diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index f0f826f..e5e0c13 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -5290,6 +5290,368 @@ fn model_env_values() -> HashMap { values } +fn home_path(parts: &[&str]) -> Option { + let mut path = dirs::home_dir()?; + for part in parts { + path.push(part); + } + Some(path) +} + +fn strip_config_value(raw: &str) -> String { + let mut out = String::new(); + let mut quote: Option = None; + for ch in raw.trim().chars() { + if ch == '"' || ch == '\'' { + if quote == Some(ch) { + quote = None; + } else if quote.is_none() { + quote = Some(ch); + } + out.push(ch); + continue; + } + if ch == '#' && quote.is_none() { + break; + } + out.push(ch); + } + let value = out.trim().trim_end_matches(',').trim(); + if value.len() >= 2 { + let bytes = value.as_bytes(); + if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') + { + return value[1..value.len() - 1].to_string(); + } + } + value.to_string() +} + +fn parse_simple_config_blocks(raw: &str) -> HashMap> { + let mut blocks: HashMap> = HashMap::new(); + let mut current = String::from(""); + blocks.entry(current.clone()).or_default(); + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + current = trimmed.trim_matches(&['[', ']'][..]).trim().to_string(); + blocks.entry(current.clone()).or_default(); + continue; + } + let Some((key, value)) = trimmed.split_once('=') else { + continue; + }; + blocks + .entry(current.clone()) + .or_default() + .insert(key.trim().to_string(), strip_config_value(value)); + } + blocks +} + +fn first_env_ref(keys: &[&str]) -> (String, String) { + for key in keys { + if std::env::var(key) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + { + return (format!("${{{key}}}"), "found".into()); + } + } + if let Some(key) = keys.first() { + (format!("${{{key}}}"), "missing".into()) + } else { + (String::new(), "none".into()) + } +} + +fn find_json_string(value: &Value, keys: &[&str], depth: usize) -> Option { + if depth > 5 { + return None; + } + match value { + Value::Object(map) => { + for key in keys { + if let Some(v) = map.get(*key).and_then(|v| v.as_str()) { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + } + for v in map.values() { + if let Some(found) = find_json_string(v, keys, depth + 1) { + return Some(found); + } + } + } + Value::Array(list) => { + for v in list { + if let Some(found) = find_json_string(v, keys, depth + 1) { + return Some(found); + } + } + } + _ => {} + } + None +} + +fn push_client_candidate( + out: &mut Vec, + id: &str, + source: &str, + source_path: &str, + provider_key: &str, + display_name: &str, + base_url: &str, + api: &str, + api_key: &str, + api_key_status: &str, + models: Vec, + importable: bool, + auth_hint: &str, + warning: &str, +) { + out.push(json!({ + "id": id, + "source": source, + "sourcePath": source_path, + "providerKey": provider_key, + "displayName": display_name, + "baseUrl": base_url, + "api": api, + "apiKey": api_key, + "apiKeyStatus": api_key_status, + "models": models, + "importable": importable, + "authHint": auth_hint, + "warning": warning, + })); +} + +fn scan_json_client_file( + out: &mut Vec, + id: &str, + source: &str, + parts: &[&str], + provider_key: &str, + display_name: &str, + base_url: &str, + api: &str, + env_keys: &[&str], + default_model: &str, +) { + let Some(path) = home_path(parts) else { + return; + }; + if !path.exists() { + return; + } + let model = fs::read_to_string(&path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|value| find_json_string(&value, &["model", "defaultModel", "modelName"], 0)) + .unwrap_or_else(|| default_model.to_string()); + let (api_key, status) = first_env_ref(env_keys); + let warning = if status == "missing" { + "未在当前进程环境中检测到对应 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。" + } else { + "" + }; + push_client_candidate( + out, + id, + source, + &path.to_string_lossy(), + provider_key, + display_name, + base_url, + api, + &api_key, + &status, + vec![model], + true, + "", + warning, + ); +} + +#[tauri::command] +pub fn scan_model_client_configs() -> Result { + let mut candidates = Vec::new(); + if let Some(path) = home_path(&[".codex", "config.toml"]) { + if let Ok(raw) = fs::read_to_string(&path) { + let blocks = parse_simple_config_blocks(&raw); + let root = blocks.get("").cloned().unwrap_or_default(); + let provider_id = root + .get("model_provider") + .cloned() + .unwrap_or_else(|| "openai".into()); + let section = blocks + .get(&format!("model_providers.{provider_id}")) + .cloned() + .unwrap_or_default(); + let model = root + .get("model") + .cloned() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "gpt-5.1-codex-mini".into()); + let base_url = section.get("base_url").cloned().unwrap_or_else(|| { + if provider_id.contains("codex") { + "https://chatgpt.com/backend-api/codex".into() + } else { + "https://api.openai.com/v1".into() + } + }); + let wire_api = section.get("wire_api").cloned().unwrap_or_default(); + let explicit_env_key = section + .get("env_key") + .cloned() + .filter(|v| is_valid_env_key(v)); + let env_key = explicit_env_key.or_else(|| { + if provider_id == "openai" { + Some("OPENAI_API_KEY".into()) + } else { + None + } + }); + let is_external_codex = + provider_id.contains("codex") || base_url.contains("chatgpt.com/backend-api/codex"); + let api = if is_external_codex { + "openai-codex-responses" + } else if wire_api.contains("responses") { + "openai-responses" + } else { + "openai-completions" + }; + let (api_key, status) = if let Some(key) = env_key.as_deref() { + if std::env::var(key) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + { + (format!("${{{key}}}"), "found") + } else { + (format!("${{{key}}}"), "missing") + } + } else { + (String::new(), "none") + }; + let provider_key = if provider_id == "openai" { + "codex-openai".to_string() + } else { + format!("codex-{provider_id}") + }; + let warning = if is_external_codex { + "ChatGPT/Codex OAuth 令牌不会导入到 OpenClaw。请优先使用 Hermes 的 openai-codex 登录。" + } else if status == "none" { + "Codex 配置没有声明可安全引用的 env_key,无法自动导入 API Key。请在 Codex 配置中添加 env_key,或在 OpenClaw 中手动配置服务商密钥。" + } else if status == "missing" { + "未在当前进程环境中检测到 Codex 配置引用的 API Key 环境变量,导入后需要在 OpenClaw env 或 .env 中补齐。" + } else { + "" + }; + push_client_candidate( + &mut candidates, + "codex-cli", + "Codex CLI", + &path.to_string_lossy(), + &provider_key, + &format!("Codex CLI / {provider_id}"), + &base_url, + api, + &api_key, + status, + vec![model], + !is_external_codex && status != "none", + if is_external_codex { + "hermes auth login openai-codex" + } else { + "" + }, + warning, + ); + } + } + scan_json_client_file( + &mut candidates, + "claude-code", + "Claude Code", + &[".claude", "settings.json"], + "anthropic", + "Anthropic / Claude Code", + "https://api.anthropic.com/v1", + "anthropic-messages", + &["ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN"], + "claude-sonnet-4-5-20250514", + ); + scan_json_client_file( + &mut candidates, + "gemini-cli", + "Gemini CLI", + &[".gemini", "settings.json"], + "google", + "Google Gemini CLI", + "https://generativelanguage.googleapis.com/v1beta", + "google-generative-ai", + &["GEMINI_API_KEY", "GOOGLE_API_KEY"], + "gemini-2.5-pro", + ); + for (env_key, provider_key, display_name, base_url, api, model) in [ + ( + "OPENAI_API_KEY", + "openai-env", + "OpenAI 环境变量", + std::env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".into()), + "openai-completions", + std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o".into()), + ), + ( + "ANTHROPIC_API_KEY", + "anthropic-env", + "Anthropic 环境变量", + "https://api.anthropic.com/v1".into(), + "anthropic-messages", + std::env::var("ANTHROPIC_MODEL") + .unwrap_or_else(|_| "claude-sonnet-4-5-20250514".into()), + ), + ( + "GEMINI_API_KEY", + "gemini-env", + "Gemini 环境变量", + "https://generativelanguage.googleapis.com/v1beta".into(), + "google-generative-ai", + std::env::var("GEMINI_MODEL").unwrap_or_else(|_| "gemini-2.5-pro".into()), + ), + ] { + if std::env::var(env_key) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + { + push_client_candidate( + &mut candidates, + provider_key, + "Environment", + env_key, + provider_key, + display_name, + &base_url, + api, + &format!("${{{env_key}}}"), + "found", + vec![model], + true, + "", + "", + ); + } + } + Ok(json!({ "candidates": candidates })) +} + fn resolve_model_api_key(api_key: &str) -> Result { let Some(key) = model_api_key_env_ref(api_key)? else { return Ok(api_key.to_string()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6e4c2e..a298ad1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,6 +97,7 @@ pub fn run() { config::test_model, config::test_model_verbose, config::list_remote_models, + config::scan_model_client_configs, config::list_openclaw_versions, config::upgrade_openclaw, config::uninstall_openclaw, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 17c7304..43c74e1 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -297,6 +297,7 @@ export const api = { testModel: (baseUrl, apiKey, modelId, apiType = null) => invoke('test_model', { baseUrl, apiKey, modelId, apiType }), testModelVerbose: (baseUrl, apiKey, modelId, apiType = null) => invoke('test_model_verbose', { baseUrl, apiKey, modelId, apiType }), listRemoteModels: (baseUrl, apiKey, apiType = null) => invoke('list_remote_models', { baseUrl, apiKey, apiType }), + scanModelClientConfigs: () => invoke('scan_model_client_configs'), // Agent 管理 listAgents: () => cachedInvoke('list_agents'), diff --git a/src/locales/modules/models.js b/src/locales/modules/models.js index 357559a..e3b12ad 100644 --- a/src/locales/modules/models.js +++ b/src/locales/modules/models.js @@ -4,6 +4,21 @@ export default { title: _('模型配置', 'Models', '模型設定', 'モデル設定', '모델 설정', 'Cấu hình mô hình', 'Configuración de modelos', 'Configuração de modelos', 'Настройка моделей', 'Configuration des modèles', 'Modell-Konfiguration'), desc: _('添加 AI 模型服务商,配置可用模型', 'Add AI model providers, configure available models', '新增 AI 模型服務商,設定可用模型', 'AI モデルプロバイダーとモデルの管理', 'AI 모델 프로바이더 및 모델 관리', 'Quản lý nhà cung cấp và mô hình AI', 'Gestionar proveedores y modelos de IA', 'Gerenciar provedores e modelos de IA', 'Управление провайдерами и моделями ИИ', 'Gérer les fournisseurs et modèles IA', 'KI-Anbieter und Modelle verwalten'), addProvider: _('+ 添加服务商', '+ Add Provider', '+ 新增服務商', '+ プロバイダー追加', '+ 프로바이더 추가', '+ Thêm nhà cung cấp', '+ Agregar proveedor', '+ Adicionar provedor', '+ Добавить провайдера', '+ Ajouter un fournisseur', '+ Anbieter hinzufügen'), + importClientConfigs: _('导入客户端配置', 'Import Client Configs', '匯入用戶端設定'), + importScanning: _('扫描中...', 'Scanning...', '掃描中...'), + importClientTitle: _('导入客户端模型配置', 'Import Client Model Configs', '匯入用戶端模型設定'), + importClientHint: _('扫描 Codex、Claude Code、Gemini CLI 和常见环境变量,只导入服务商、模型与环境变量引用,不读取或复制 OAuth Token。', 'Scan Codex, Claude Code, Gemini CLI, and common environment variables. Only providers, models, and environment variable references are imported; OAuth tokens are not read or copied.', '掃描 Codex、Claude Code、Gemini CLI 和常見環境變數,只匯入服務商、模型與環境變數引用,不讀取或複製 OAuth Token。'), + importSelected: _('导入选中项', 'Import Selected', '匯入選中項'), + importNoneSelected: _('请先选择要导入的配置', 'Select configs to import first', '請先選擇要匯入的設定'), + importNoneFound: _('未发现可导入的客户端配置', 'No importable client configs found', '未發現可匯入的用戶端設定'), + importScanFailed: _('扫描客户端配置失败', 'Failed to scan client configs', '掃描用戶端設定失敗'), + importDone: _('已导入 {count} 个服务商', 'Imported {count} provider(s)', '已匯入 {count} 個服務商'), + importKeyFound: _('已检测到', 'Detected', '已偵測到'), + importKeyMissing: _('待补齐', 'Missing', '待補齊'), + importKeyNone: _('无需密钥', 'No key needed', '無需金鑰'), + importAuthHint: _('登录提示', 'Auth hint', '登入提示'), + importNoModels: _('未识别到模型,无法导入', 'No model was detected, cannot import', '未識別到模型,無法匯入'), + importNoImportable: _('没有可导入的配置', 'No importable configs', '沒有可匯入的設定'), undo: _('↩ 撤销', '↩ Undo'), undoN: _('↩ 撤销 ({n})', '↩ Undo ({n})'), undone: _('已撤销', 'Undone'), diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 370e0d0..14753c3 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -14,6 +14,8 @@ import { QTCOOL, PROVIDER_PRESETS, API_TYPES as SHARED_API_TYPES, fetchQtcoolMod import { t } from '../lib/i18n.js' import { getActiveEngineId } from '../lib/engine-manager.js' import { enhanceModelCallError } from '../lib/model-error-diagnosis.js' +import { getFieldSchema } from '../lib/config-schema.js' +import { wsClient } from '../lib/ws-client.js' // ── 常量 ── const STORAGE_KEY = 'clawpanel-assistant' @@ -324,6 +326,80 @@ const TOOL_DEFS = { }, }, ], + openclaw: [ + { + type: 'function', + function: { + name: 'get_openclaw_context', + description: '获取当前 OpenClaw 实例的脱敏上下文快照,包括配置目录、版本、Gateway 状态、模型服务商、主模型、Agent、消息渠道和路由绑定。不会返回 API Key 明文。', + parameters: { + type: 'object', + properties: { + format: { type: 'string', enum: ['markdown', 'json'], description: '返回格式,默认 markdown' }, + refresh: { type: 'boolean', description: '是否强制刷新缓存,默认 false' }, + }, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'diagnose_openclaw', + description: '对当前 OpenClaw 配置和运行状态做结构化诊断。可诊断 all、gateway、models、channels、agents,并返回问题、证据和建议。只读,不会修改配置。', + parameters: { + type: 'object', + properties: { + component: { type: 'string', enum: ['all', 'gateway', 'models', 'channels', 'agents'], description: '诊断范围,默认 all' }, + deep: { type: 'boolean', description: '是否附加 Gateway 连接诊断和最近日志摘要,默认 false' }, + }, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'get_openclaw_schema_graph', + description: '获取 OpenClaw 配置 schema 知识图谱,说明关键字段、类型、关系、常见风险和当前实例中的配置事实。用于判断字段应该放在哪里、哪些字段互相关联、配置错误如何修复。只读。', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: '可选,聚焦某个配置路径,如 gateway、models.providers、bindings、channels.feishu' }, + format: { type: 'string', enum: ['markdown', 'json'], description: '返回格式,默认 markdown' }, + includeLiveContext: { type: 'boolean', description: '是否附加当前实例的脱敏配置事实,默认 true' }, + }, + required: [], + }, + }, + }, + ], + browser: [ + { + type: 'function', + function: { + name: 'browser_action', + description: '使用本机 Playwright CLI 操作一个浏览器会话。支持打开网页、跳转、获取可交互快照、点击、填表、输入、按键、截图、读取 console 和网络请求。依赖可选外部命令 playwright-cli;未安装时会返回安装提示。浏览器操作可能访问外部网站或触发网页动作,执行前通常需要用户确认。', + parameters: { + type: 'object', + properties: { + action: { type: 'string', enum: ['open', 'goto', 'snapshot', 'click', 'fill', 'type', 'press', 'screenshot', 'console', 'requests', 'close'], description: '浏览器动作' }, + url: { type: 'string', description: 'open/goto 使用的 URL' }, + ref: { type: 'string', description: 'snapshot 返回的元素 ref、CSS 选择器或 Playwright locator,用于 click/fill/screenshot' }, + text: { type: 'string', description: 'fill/type 使用的文本' }, + key: { type: 'string', description: 'press 使用的按键,如 Enter、Escape、ArrowDown' }, + filename: { type: 'string', description: 'snapshot/screenshot 输出文件名,可选' }, + session: { type: 'string', description: 'Playwright CLI 会话名,默认 clawpanel-assistant' }, + depth: { type: 'integer', description: 'snapshot 深度限制,可选' }, + button: { type: 'string', enum: ['left', 'right', 'middle'], description: 'click 使用的鼠标按钮,默认 left' }, + submit: { type: 'boolean', description: 'fill 后是否按 Enter 提交' }, + browser: { type: 'string', enum: ['chromium', 'chrome', 'msedge', 'firefox', 'webkit'], description: 'open 时使用的浏览器,可选' }, + }, + required: ['action'], + }, + }, + }, + ], interaction: [ { type: 'function', @@ -549,14 +625,14 @@ const BUILTIN_SKILLS = [ prompt: `请帮我检查 OpenClaw 的配置文件。 具体操作: -1. 调用 get_system_info 获取系统信息,确定主目录和 OS 类型 -2. 用 list_directory 查看 ~/.openclaw/ 目录结构 -3. 用 read_file 读取 ~/.openclaw/openclaw.json -4. 分析配置内容,检查: +1. 先调用 get_openclaw_context 获取当前 OpenClaw 脱敏实况 +2. 再调用 diagnose_openclaw,component=models,deep=false +3. 结合上下文快照分析配置内容,检查: - models.providers 服务商配置(baseUrl 格式、apiKey 是否存在) - gateway 配置(port 默认 18789、mode 必须在 gateway 对象内) - 常见配置错误(mode 放在顶层、缺少 gateway 对象、controlUi.allowedOrigins 未配置) -5. 给出配置健康度评估和具体改进建议`, +4. 给出配置健康度评估和具体改进建议 +5. 不要要求用户粘贴 API Key,也不要输出密钥明文`, }, { id: 'diagnose-gateway', @@ -567,12 +643,10 @@ const BUILTIN_SKILLS = [ prompt: `请帮我诊断 OpenClaw Gateway 的运行状态。 具体操作: -1. 调用 get_system_info 获取 OS 类型和主目录 -2. 用 list_processes 工具检查 openclaw/gateway 进程是否在运行 -3. 用 check_port 工具检查端口 18789 是否在监听 -4. 用 read_file 读取 ~/.openclaw/logs/gateway.log(取最后 50 行) -5. 分析日志中的 ERROR、WARN、fail 等关键词 -6. 给出诊断结论(进程状态 + 端口状态 + 日志分析)和修复建议`, +1. 先调用 diagnose_openclaw,component=gateway,deep=true +2. 如果 deep 诊断不足,再用 list_processes/check_port 做补充 +3. 分析 Gateway 服务状态、连接诊断和最近日志中的 ERROR、WARN、fail 等关键词 +4. 给出诊断结论(服务状态 + 端口状态 + 日志分析)和修复建议`, }, { id: 'browse-dir', @@ -636,12 +710,12 @@ const BUILTIN_SKILLS = [ tools: ['terminal', 'fileOps'], prompt: `请帮我自动检测并修复 OpenClaw 的常见问题。 -先调用 get_system_info 获取系统信息,然后按以下步骤逐一检查: -1. **配置检查**:用 read_file 读取 openclaw.json,检查是否有已知错误(mode 在顶层、缺少 gateway 对象等) -2. **models.json 同步**:用 read_file 对比 openclaw.json 和 agents/main/agent/models.json 的 providers -3. **Gateway 状态**:用 list_processes 检查 openclaw 进程,用 check_port 检查端口 18789 -4. **WebSocket 配置**:检查 gateway.controlUi.allowedOrigins 是否包含 "*" -5. **Node.js 环境**:用 run_command 检查 node 和 npm 版本 +先调用 diagnose_openclaw,component=all,deep=true,然后按以下步骤逐一检查: +1. **配置检查**:检查 openclaw.json 脱敏摘要,识别已知错误(mode 在顶层、缺少 gateway 对象等) +2. **模型链路**:检查 provider、主模型、fallbacks、env 引用和模型 ID +3. **Gateway 状态**:检查服务状态、连接诊断和端口 18789 +4. **消息渠道**:检查渠道、账号和 Agent 绑定风险 +5. **Node.js 环境**:必要时再用 run_command 检查 node 和 npm 版本 对每个检查项给出通过/失败状态,并对发现的问题给出具体修复命令(但不要自动修改配置文件,等我确认)。`, }, @@ -876,6 +950,10 @@ function getEnabledTools() { const tc = _config.tools || {} const tools = [...TOOL_DEFS.system, ...TOOL_DEFS.process, ...TOOL_DEFS.interaction] + if (getActiveEngineId() !== 'hermes') tools.push(...TOOL_DEFS.openclaw) + + if (tc.browser !== false) tools.push(...TOOL_DEFS.browser) + // 终端工具:受设置开关控制(优先级高于模式) if (tc.terminal !== false) tools.push(...TOOL_DEFS.terminal) @@ -1053,6 +1131,10 @@ function buildSystemPrompt() { prompt += '\n- **系统信息**: get_system_info — 获取 OS 类型、架构、主目录等。**在执行任何命令前必须先调用此工具**。' prompt += '\n- **进程/端口**: list_processes(按名称过滤)、check_port(检测端口占用)' prompt += '\n- **终端**: run_command — 执行 shell 命令' + if (getActiveEngineId() !== 'hermes') { + prompt += '\n- **OpenClaw 实况**: get_openclaw_context、diagnose_openclaw、get_openclaw_schema_graph — 读取当前实例和配置 schema 知识图谱。涉及 OpenClaw 配置字段位置/关系时优先调用。' + } + prompt += '\n- **浏览器**: browser_action — 使用 Playwright CLI 打开网页、获取快照、点击、填表、截图、查看 console/requests。网页写入动作会要求用户确认。' if (mode.readOnly) { prompt += '\n- **文件**: read_file、list_directory(只读,write_file 已禁用)' } else { @@ -1112,6 +1194,23 @@ function buildSystemPrompt() { return prompt } +async function buildSystemPromptAsync() { + let prompt = buildSystemPrompt() + if (getActiveEngineId() !== 'hermes') { + try { + const ctx = await collectOpenClawContext() + if (ctx?.markdown) { + prompt += '\n\n## 当前机器 OpenClaw 实况' + prompt += '\n以下内容是 ClawPanel 自动读取的当前实例脱敏快照;回答模型、Gateway、Agent、消息渠道问题时必须优先参考它,而不是只依赖静态知识。' + prompt += '\n\n' + ctx.markdown + } + } catch (e) { + prompt += `\n\n## 当前机器 OpenClaw 实况\n- 自动读取失败: ${e?.message || e}` + } + } + return prompt +} + // ── 灵魂移植:扫描可用 Agent ── async function scanOpenClawAgents() { try { @@ -1254,6 +1353,587 @@ function renderSoulStats(soul) { return html } +const OPENCLAW_CONTEXT_TTL = 15000 +let _openclawContextCache = null +let _openclawContextLoadedAt = 0 +let _openclawContextPromise = null + +function isObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) +} + +function asArray(value) { + return Array.isArray(value) ? value : [] +} + +function resultArray(value, key) { + if (Array.isArray(value)) return value + if (key && Array.isArray(value?.[key])) return value[key] + return [] +} + +function safeEntries(value) { + return Object.entries(isObject(value) ? value : {}) +} + +function modelId(model) { + return typeof model === 'string' ? model : (model?.id || model?.name || '') +} + +function providerModels(provider) { + return asArray(provider?.models).map(modelId).filter(Boolean) +} + +function envRefName(value) { + const text = String(value || '').trim() + return text.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1] || text.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/)?.[1] || '' +} + +function secretStatus(value, envMap) { + const text = String(value || '').trim() + if (!text) return { kind: 'none', label: '未配置' } + const envName = envRefName(text) + if (envName) { + return { + kind: envMap.has(envName) ? 'env-configured' : 'env-ref', + label: envMap.has(envName) ? `环境变量引用 ${envName}(已在 OpenClaw env 中配置)` : `环境变量引用 ${envName}(未在 OpenClaw env 中找到)`, + envName, + configuredInOpenclawEnv: envMap.has(envName), + } + } + return { kind: 'literal', label: '已配置明文/令牌(已脱敏)' } +} + +function cleanUrl(url) { + try { + const u = new URL(String(url || '')) + u.username = '' + u.password = '' + u.search = '' + u.hash = '' + return u.toString().replace(/\/$/, '') + } catch { + return String(url || '').replace(/[?].*$/, '') + } +} + +function redactSensitive(value, depth = 0) { + if (depth > 6) return '[Object]' + if (Array.isArray(value)) return value.slice(0, 30).map(v => redactSensitive(v, depth + 1)) + if (!isObject(value)) return value + const out = {} + for (const [key, val] of Object.entries(value)) { + if (/(api[-_]?key|secret|token|password|credential|authorization|bearer|cookie|private)/i.test(key)) { + out[key] = val ? '[REDACTED]' : val + } else { + out[key] = redactSensitive(val, depth + 1) + } + } + return out +} + +function getPrimaryModel(config) { + return config?.agents?.defaults?.model?.primary || config?.models?.primary || config?.model?.primary || '' +} + +function summarizeProvider(key, provider, envMap) { + const models = providerModels(provider) + const secret = secretStatus(provider?.apiKey, envMap) + return { + key, + api: provider?.api || 'openai-completions', + baseUrl: cleanUrl(provider?.baseUrl || ''), + apiKeyStatus: secret.label, + apiKeyKind: secret.kind, + envName: secret.envName || '', + modelCount: models.length, + allModelIds: models, + models: models.slice(0, 12), + truncatedModels: Math.max(0, models.length - 12), + } +} + +function summarizeAgents(agents, config) { + const list = resultArray(agents, 'agents') + if (list.length) { + return list.slice(0, 20).map(a => ({ + id: a.id || a.name || 'unknown', + name: a.name || a.id || 'unknown', + model: a.model || a.primaryModel || a.config?.model || '', + workspace: a.workspace || a.workspacePath || '', + enabled: a.enabled !== false, + })) + } + const cfgAgents = asArray(config?.agents?.list) + return cfgAgents.slice(0, 20).map(a => ({ + id: a.id || a.name || 'unknown', + name: a.name || a.id || 'unknown', + model: a.model || a.model?.primary || '', + workspace: a.workspace || '', + enabled: a.enabled !== false, + })) +} + +function summarizeChannels(platforms, config) { + const list = resultArray(platforms, 'platforms') + if (list.length) { + return list.slice(0, 24).map(p => ({ + id: p.id || p.platform || 'unknown', + enabled: p.enabled !== false, + accounts: asArray(p.accounts).map(a => a.accountId || a.id || 'default').slice(0, 8), + accountCount: asArray(p.accounts).length, + })) + } + return safeEntries(config?.channels).slice(0, 24).map(([id, value]) => ({ + id, + enabled: value?.enabled !== false, + accounts: asArray(value?.accounts).map(a => a.accountId || a.id || 'default').slice(0, 8), + accountCount: asArray(value?.accounts).length, + })) +} + +function summarizeBindings(bindings, config) { + const raw = resultArray(bindings, 'bindings') + const list = raw.length ? raw : asArray(config?.bindings) + return list.slice(0, 30).map(b => ({ + agentId: b.agentId || 'main', + channel: b.match?.channel || b.channel || '', + accountId: b.match?.accountId || b.accountId || '', + peer: b.match?.peer || '', + guildId: b.match?.guildId || '', + teamId: b.match?.teamId || '', + roles: asArray(b.match?.roles).slice(0, 6), + })) +} + +function gatewayFromServices(services) { + const list = asArray(services) + const item = list.find(s => /gateway/i.test(String(s.label || s.name || s.id || ''))) || list[0] + return item ? { + label: item.label || item.name || item.id || 'gateway', + running: item.running === true || item.status === 'running', + pid: item.pid || null, + status: item.status || '', + } : null +} + +function createOpenClawFindings(ctx, component = 'all') { + const findings = [] + const want = (name) => component === 'all' || component === name + const providers = ctx.providers || [] + const providerMap = new Map(providers.map(p => [p.key, p])) + const primary = ctx.primaryModel || '' + const [primaryProvider, primaryModel] = primary.split('/') + if (want('gateway')) { + if (!ctx.gateway) findings.push({ level: 'warn', component: 'gateway', title: '无法读取 Gateway 服务状态', evidence: 'get_services_status 未返回可识别的 Gateway 项', suggestion: '在服务管理页或使用 openclaw gateway status --deep 进一步确认。' }) + else if (!ctx.gateway.running) findings.push({ level: 'warn', component: 'gateway', title: 'Gateway 当前未运行', evidence: `${ctx.gateway.label}: ${ctx.gateway.status || 'not running'}`, suggestion: '可在服务管理页启动 Gateway,或先检查端口/日志定位启动失败原因。' }) + const gw = ctx.configSummary?.gateway || {} + if (ctx.configSummary?.topLevelMode) findings.push({ level: 'error', component: 'gateway', title: '检测到顶层 mode 配置', evidence: `mode=${ctx.configSummary.topLevelMode}`, suggestion: 'mode 应放在 gateway.mode 下,顶层 mode 会触发 OpenClaw schema 错误。' }) + const origins = gw.controlUi?.allowedOrigins + if (origins && Array.isArray(origins) && !origins.includes('*') && !origins.includes('http://localhost:*')) findings.push({ level: 'info', component: 'gateway', title: 'controlUi.allowedOrigins 较严格', evidence: origins.join(', '), suggestion: '如果浏览器/WebSocket 连接失败,可检查是否需要允许当前面板来源。' }) + } + if (want('models')) { + if (!providers.length) findings.push({ level: 'error', component: 'models', title: '没有配置模型服务商', evidence: 'models.providers 为空', suggestion: '在模型配置页添加至少一个 provider 和模型。' }) + if (!primary) findings.push({ level: 'warn', component: 'models', title: '未设置默认主模型', evidence: 'agents.defaults.model.primary 为空', suggestion: '在模型配置页选择一个主模型。' }) + else if (!providerMap.has(primaryProvider)) findings.push({ level: 'error', component: 'models', title: '主模型引用了不存在的 provider', evidence: primary, suggestion: `添加 provider ${primaryProvider},或重新选择主模型。` }) + else if (primaryModel && !providerMap.get(primaryProvider).allModelIds.includes(primaryModel)) findings.push({ level: 'warn', component: 'models', title: '主模型不在 provider 模型列表中', evidence: primary, suggestion: '确认模型 ID 是否拼写正确,或重新拉取模型列表。' }) + for (const p of providers) { + if (p.apiKeyKind === 'none' && !/ollama/i.test(p.api || '') && !/:11434/.test(p.baseUrl)) findings.push({ level: 'warn', component: 'models', title: `Provider ${p.key} 未配置 API Key`, evidence: `${p.api} ${p.baseUrl}`, suggestion: '补充 apiKey,或使用 ${ENV_VAR} 引用 OpenClaw env。' }) + if (p.apiKeyKind === 'env-ref') findings.push({ level: 'warn', component: 'models', title: `Provider ${p.key} 引用了未记录的环境变量`, evidence: p.envName, suggestion: `在 OpenClaw env/.env 中补齐 ${p.envName},或改成已有环境变量引用。` }) + if (/chatgpt\.com\/backend-api\/codex/i.test(p.baseUrl)) findings.push({ level: 'info', component: 'models', title: `Provider ${p.key} 指向 ChatGPT/Codex OAuth 接口`, evidence: p.baseUrl, suggestion: '该接口通常依赖 OAuth 会话,不应当当作普通 API Key provider 导入。' }) + } + } + if (want('agents')) { + if (!ctx.agents.length) findings.push({ level: 'warn', component: 'agents', title: '未读取到 Agent 列表', evidence: 'list_agents 为空', suggestion: '检查 agents.list 与 ~/.openclaw/agents 目录是否正常。' }) + } + if (want('channels')) { + const agentIds = new Set(ctx.agents.map(a => a.id).concat(['main', 'default'])) + const channelIds = new Set(ctx.channels.map(c => c.id)) + for (const b of ctx.bindings) { + if (b.agentId && !agentIds.has(b.agentId)) findings.push({ level: 'warn', component: 'channels', title: '路由绑定引用未知 Agent', evidence: `${b.channel || '*'} -> ${b.agentId}`, suggestion: '在 Agent 管理或消息渠道绑定中修正该 binding。' }) + if (b.channel && channelIds.size && !channelIds.has(b.channel)) findings.push({ level: 'info', component: 'channels', title: '路由绑定引用了未展示的渠道', evidence: b.channel, suggestion: '确认该渠道是否为插件渠道或历史配置残留。' }) + } + if (ctx.channels.length && !ctx.bindings.length) findings.push({ level: 'info', component: 'channels', title: '已配置消息渠道但没有显式 Agent 绑定', evidence: `${ctx.channels.length} 个渠道`, suggestion: '未绑定时通常走默认 Agent;如需多 Agent 分流,请配置 bindings。' }) + } + return findings +} + +function formatOpenClawContext(ctx) { + const lines = [] + lines.push('## 当前 OpenClaw 实况(自动脱敏)') + lines.push(`- 配置目录: ${ctx.openclawDir || '未知'}`) + lines.push(`- OpenClaw 版本: ${ctx.version || '未知'}`) + lines.push(`- Gateway: ${ctx.gateway ? `${ctx.gateway.running ? '运行中' : '未运行'}${ctx.gateway.pid ? ` (pid ${ctx.gateway.pid})` : ''}` : '未知'}`) + lines.push(`- 主模型: ${ctx.primaryModel || '未设置'}`) + lines.push(`- Provider: ${ctx.providers.length} 个`) + for (const p of ctx.providers.slice(0, 8)) { + lines.push(` - ${p.key}: ${p.api}, ${p.modelCount} models, key=${p.apiKeyStatus}, base=${p.baseUrl || '-'}`) + } + if (ctx.providers.length > 8) lines.push(` - ...还有 ${ctx.providers.length - 8} 个 provider`) + lines.push(`- Agent: ${ctx.agents.length} 个${ctx.agents.length ? ' — ' + ctx.agents.slice(0, 8).map(a => `${a.id}${a.model ? `(${a.model})` : ''}`).join(', ') : ''}`) + lines.push(`- 消息渠道: ${ctx.channels.length} 个${ctx.channels.length ? ' — ' + ctx.channels.slice(0, 8).map(c => `${c.id}${c.enabled ? '' : '(disabled)'}`).join(', ') : ''}`) + lines.push(`- 路由绑定: ${ctx.bindings.length} 条`) + if (ctx.findings.length) { + lines.push('### 自动发现的问题/风险') + for (const f of ctx.findings.slice(0, 10)) lines.push(`- [${f.level}] ${f.component}: ${f.title} — ${f.evidence}`) + } else { + lines.push('### 自动发现的问题/风险') + lines.push('- 未发现明显配置风险') + } + lines.push('注意:以上快照不会包含 API Key 明文。遇到模型、Gateway、渠道问题时,优先调用 get_openclaw_context 或 diagnose_openclaw 获取最新证据。') + return lines.join('\n') +} + +async function collectOpenClawContext({ refresh = false } = {}) { + if (getActiveEngineId() === 'hermes') return null + const now = Date.now() + if (!refresh && _openclawContextCache && now - _openclawContextLoadedAt < OPENCLAW_CONTEXT_TTL) return _openclawContextCache + if (_openclawContextPromise && !refresh) return await _openclawContextPromise + _openclawContextPromise = (async () => { + const settled = await Promise.allSettled([ + api.getOpenclawDir(), + api.getVersionInfo(), + api.getServicesStatus(), + api.readOpenclawConfig(), + api.listAgents(), + api.listConfiguredPlatforms(), + api.listAllBindings(), + api.getStatusSummary(), + ]) + const pick = (idx, fallback) => settled[idx].status === 'fulfilled' ? settled[idx].value : fallback + const config = pick(3, {}) || {} + const envMap = new Map(safeEntries(config.env).map(([k, v]) => [k, v])) + const providers = safeEntries(config.models?.providers).map(([key, provider]) => summarizeProvider(key, provider, envMap)) + const ctx = { + generatedAt: new Date().toISOString(), + openclawDir: pick(0, ''), + version: pick(1, {})?.current || pick(1, {})?.version || '', + gateway: gatewayFromServices(pick(2, [])), + primaryModel: getPrimaryModel(config), + providers, + agents: summarizeAgents(pick(4, []), config), + channels: summarizeChannels(pick(5, []), config), + bindings: summarizeBindings(pick(6, []), config), + statusSummary: pick(7, null), + configSummary: { + topLevelMode: config.mode || '', + gateway: redactSensitive(config.gateway || {}), + modelFallbacks: asArray(config.agents?.defaults?.model?.fallbacks).slice(0, 10), + envKeys: [...envMap.keys()].slice(0, 50), + }, + readErrors: settled.map((r, idx) => r.status === 'rejected' ? `${idx}: ${r.reason?.message || r.reason}` : '').filter(Boolean), + } + ctx.findings = createOpenClawFindings(ctx, 'all') + ctx.markdown = formatOpenClawContext(ctx) + _openclawContextCache = ctx + _openclawContextLoadedAt = Date.now() + return ctx + })() + try { + return await _openclawContextPromise + } finally { + _openclawContextPromise = null + } +} + +async function openClawContextTool(args = {}) { + const ctx = await collectOpenClawContext({ refresh: args.refresh === true }) + if (!ctx) return '当前处于 Hermes Agent 引擎,OpenClaw 上下文工具未启用。' + if (args.format === 'json') { + const { markdown, ...rest } = ctx + rest.providers = rest.providers.map(({ allModelIds, ...p }) => p) + return JSON.stringify(rest, null, 2) + } + return ctx.markdown +} + +async function diagnoseOpenClawTool(args = {}) { + const component = args.component || 'all' + const ctx = await collectOpenClawContext({ refresh: true }) + if (!ctx) return '当前处于 Hermes Agent 引擎,OpenClaw 诊断工具未启用。' + const findings = createOpenClawFindings(ctx, component) + let output = `## OpenClaw 诊断:${component}\n\n` + output += ctx.markdown + '\n\n' + if (findings.length) { + output += '## 诊断结论\n' + output += findings.map(f => `- **${f.level.toUpperCase()} / ${f.component}** ${f.title}\n - 证据: ${f.evidence}\n - 建议: ${f.suggestion}`).join('\n') + } else { + output += '## 诊断结论\n- 未发现明显问题。\n' + } + if (args.deep === true && (component === 'all' || component === 'gateway')) { + try { + output += '\n\n## Gateway 连接深度诊断\n' + output += JSON.stringify(await api.diagnoseGatewayConnection(), null, 2) + } catch (e) { + output += `\n\n## Gateway 连接深度诊断\n读取失败: ${e?.message || e}` + } + try { + const tail = await api.readLogTail('gateway', 80) + if (tail?.trim()) output += `\n\n## Gateway 最近日志(尾部)\n\`\`\`\n${tail.trim().split('\n').slice(-80).join('\n')}\n\`\`\`` + } catch {} + } + return output +} + +const OPENCLAW_SCHEMA_GRAPH_SEEDS = [ + { path: 'gateway', type: 'object', description: 'Gateway 服务配置根节点,所有 Gateway 运行模式、端口和控制台安全设置都应放在这里。', risks: ['不要把 mode 放到 openclaw.json 顶层。'] }, + { path: 'gateway.mode', type: 'string', description: 'Gateway 运行模式。', risks: ['顶层 mode 会导致 schema 不匹配。'] }, + { path: 'gateway.host', type: 'string', description: 'Gateway HTTP 监听地址。' }, + { path: 'gateway.port', type: 'integer', description: 'Gateway HTTP 监听端口,常见默认值为 18789。', risks: ['端口被占用会导致 Gateway 启动失败。'] }, + { path: 'gateway.controlUi', type: 'object', description: '浏览器控制台 / WebSocket 控制面相关配置。' }, + { path: 'gateway.controlUi.allowedOrigins', type: 'array', description: '允许连接控制台的浏览器来源列表。', risks: ['过严会导致 Web 管理面板无法连接;过宽则需要配合鉴权。'] }, + { path: 'models', type: 'object', description: '模型配置根节点。' }, + { path: 'models.providers', type: 'object', description: '模型服务商字典,key 是 provider 名称。' }, + { path: 'models.providers.*.api', type: 'string', description: '服务商 API 协议类型,例如 openai-completions、openai-responses、google-generative-ai、anthropic-messages、ollama。' }, + { path: 'models.providers.*.baseUrl', type: 'string', description: '服务商 API 地址。', risks: ['OpenAI 兼容接口通常需要 /v1;Ollama 原生接口通常不需要 /v1。'] }, + { path: 'models.providers.*.apiKey', type: 'string', description: '服务商密钥,推荐使用 ${ENV_VAR} 引用。', risks: ['不要在日志、截图或助手回复中暴露密钥明文。'] }, + { path: 'models.providers.*.models', type: 'array', description: '该服务商可用模型 ID 列表。' }, + { path: 'agents', type: 'object', description: 'Agent 全局配置。' }, + { path: 'agents.defaults', type: 'object', description: '默认 Agent 配置。' }, + { path: 'agents.defaults.model.primary', type: 'string', description: '默认主模型,通常格式为 provider/model-id。', risks: ['provider 必须存在于 models.providers;model-id 应存在于该 provider 的 models 列表中。'] }, + { path: 'agents.defaults.model.fallbacks', type: 'array', description: '默认备用模型链。' }, + { path: 'agents.list', type: 'array', description: '显式 Agent 列表;main Agent 可能是隐式存在的。' }, + { path: 'agents.list[].id', type: 'string', description: 'Agent ID,用于 bindings.agentId 引用。' }, + { path: 'agents.list[].workspace', type: 'string', description: 'Agent 工作目录。' }, + { path: 'channels', type: 'object', description: '消息渠道配置根节点,例如 feishu、telegram、discord、qqbot、dingtalk。' }, + { path: 'channels.*.enabled', type: 'boolean', description: '渠道是否启用。' }, + { path: 'channels.*.accounts', type: 'object|array', description: '多账号配置容器;accountId 用于与 bindings.match.accountId 对齐。' }, + { path: 'bindings', type: 'array', description: '消息路由绑定数组,用于把渠道事件分发给指定 Agent。' }, + { path: 'bindings[].agentId', type: 'string', description: '目标 Agent ID。', risks: ['必须引用存在的 Agent;main/default 可作为默认目标。'] }, + { path: 'bindings[].match.channel', type: 'string', description: '匹配的消息渠道。' }, + { path: 'bindings[].match.accountId', type: 'string', description: '同一渠道多账号时的账号 ID。' }, + { path: 'bindings[].match.peer', type: 'string', description: '最高优先级的会话/用户/群匹配字段。' }, + { path: 'bindings[].match.guildId', type: 'string', description: 'Discord/群组类渠道的 guild 匹配字段。' }, + { path: 'bindings[].match.teamId', type: 'string', description: '团队/租户匹配字段。' }, + { path: 'bindings[].match.roles', type: 'array', description: '角色匹配字段。' }, + { path: 'env', type: 'object', description: 'OpenClaw 环境变量映射,用于 provider apiKey 等字段的 ${ENV_VAR} 引用。' }, +] + +const OPENCLAW_SCHEMA_GRAPH_EDGES = [ + { from: 'agents.defaults.model.primary', to: 'models.providers', relation: 'references provider/model-id' }, + { from: 'agents.defaults.model.fallbacks', to: 'models.providers', relation: 'references provider/model-id' }, + { from: 'models.providers.*.apiKey', to: 'env', relation: 'may reference ${ENV_VAR}' }, + { from: 'bindings[].agentId', to: 'agents.list[].id', relation: 'routes message to agent' }, + { from: 'bindings[].match.channel', to: 'channels', relation: 'matches channel config key' }, + { from: 'bindings[].match.accountId', to: 'channels.*.accounts', relation: 'matches channel account' }, + { from: 'gateway.controlUi.allowedOrigins', to: 'ClawPanel WebSocket origin', relation: 'allows browser control connection' }, +] + +const BROWSER_READ_ACTIONS = new Set(['open', 'goto', 'snapshot', 'screenshot', 'console', 'requests', 'close']) +const BROWSER_WRITE_ACTIONS = new Set(['click', 'fill', 'type', 'press']) + +function filterSchemaGraphSeeds(path) { + const focus = String(path || '').trim() + if (!focus) return OPENCLAW_SCHEMA_GRAPH_SEEDS + return OPENCLAW_SCHEMA_GRAPH_SEEDS.filter(seed => { + return seed.path === focus || seed.path.startsWith(focus + '.') || focus.startsWith(seed.path + '.') || seed.path.includes(focus) + }) +} + +function schemaConstraints(schema) { + return schema?.schema || schema || {} +} + +function schemaTypeOf(schema, fallback) { + const c = schemaConstraints(schema) + const type = c.type || fallback || '' + return Array.isArray(type) ? type.join('|') : String(type || '') +} + +function schemaRequiredOf(schema) { + const c = schemaConstraints(schema) + return c.required === true || (Array.isArray(c.required) && c.required.length > 0) +} + +function schemaEnumOf(schema) { + const c = schemaConstraints(schema) + return Array.isArray(c.enum) ? c.enum : [] +} + +function canLookupSchemaPath(path) { + return !/[*[\]]/.test(path) +} + +function schemaLiveFact(seed, ctx) { + if (!ctx) return '' + const path = seed.path + if (path === 'gateway') return ctx.gateway ? `${ctx.gateway.running ? '运行中' : '未运行'}${ctx.gateway.pid ? ` pid=${ctx.gateway.pid}` : ''}` : '未读取到 Gateway 状态' + if (path === 'gateway.mode') return ctx.configSummary?.gateway?.mode || ctx.configSummary?.topLevelMode || '' + if (path === 'gateway.port') return ctx.configSummary?.gateway?.port ? String(ctx.configSummary.gateway.port) : '' + if (path === 'gateway.controlUi.allowedOrigins') return asArray(ctx.configSummary?.gateway?.controlUi?.allowedOrigins).join(', ') + if (path === 'models.providers') return `${ctx.providers.length} 个 provider` + if (path.startsWith('models.providers.*')) return ctx.providers.slice(0, 6).map(p => p.key).join(', ') + if (path === 'agents.defaults.model.primary') return ctx.primaryModel || '' + if (path === 'agents.defaults.model.fallbacks') return asArray(ctx.configSummary?.modelFallbacks).join(', ') + if (path === 'agents.list') return `${ctx.agents.length} 个 Agent` + if (path === 'channels') return `${ctx.channels.length} 个渠道` + if (path.startsWith('channels.*')) return ctx.channels.slice(0, 8).map(c => c.id).join(', ') + if (path === 'bindings') return `${ctx.bindings.length} 条绑定` + if (path.startsWith('bindings[]')) return ctx.bindings.slice(0, 6).map(b => `${b.channel || '*'}${b.accountId ? '/' + b.accountId : ''}->${b.agentId}`).join(', ') + if (path === 'env') return `${asArray(ctx.configSummary?.envKeys).length} 个 env key` + return '' +} + +async function collectOpenClawSchemaGraph(args = {}) { + const includeLiveContext = args.includeLiveContext !== false + const ctx = includeLiveContext ? await collectOpenClawContext().catch(() => null) : null + const seeds = filterSchemaGraphSeeds(args.path) + const nodes = await Promise.all(seeds.map(async seed => { + const remoteSchema = wsClient.connected && wsClient.gatewayReady && canLookupSchemaPath(seed.path) ? await getFieldSchema(seed.path).catch(() => null) : null + return { + path: seed.path, + type: schemaTypeOf(remoteSchema, seed.type), + description: seed.description, + required: schemaRequiredOf(remoteSchema), + enum: schemaEnumOf(remoteSchema), + risks: seed.risks || [], + live: schemaLiveFact(seed, ctx), + source: remoteSchema ? 'gateway.schema.lookup' : 'fallback', + } + })) + const nodePaths = new Set(nodes.map(n => n.path)) + const edges = OPENCLAW_SCHEMA_GRAPH_EDGES.filter(e => { + if (!args.path) return true + return nodePaths.has(e.from) || nodePaths.has(e.to) || e.from.startsWith(args.path + '.') || e.to.startsWith(args.path + '.') + }) + return { + generatedAt: new Date().toISOString(), + focus: args.path || '', + source: nodes.some(n => n.source === 'gateway.schema.lookup') ? 'gateway.schema.lookup + fallback' : 'fallback', + nodes, + edges, + liveContext: ctx ? { + primaryModel: ctx.primaryModel, + providers: ctx.providers.map(({ allModelIds, ...p }) => p), + agents: ctx.agents, + channels: ctx.channels, + bindings: ctx.bindings, + findings: ctx.findings, + } : null, + } +} + +function formatOpenClawSchemaGraph(graph) { + const lines = [] + lines.push('## OpenClaw Schema 知识图谱') + lines.push(`- 数据源: ${graph.source}`) + if (graph.focus) lines.push(`- 聚焦路径: ${graph.focus}`) + lines.push(`- 节点数: ${graph.nodes.length}`) + lines.push('') + lines.push('### 关键字段') + for (const node of graph.nodes.slice(0, 40)) { + const meta = [`type=${node.type || 'unknown'}`] + if (node.required) meta.push('required') + if (node.enum?.length) meta.push(`enum=${node.enum.join('|')}`) + if (node.live) meta.push(`当前=${node.live}`) + lines.push(`- \`${node.path}\` (${meta.join(', ')}) — ${node.description}`) + for (const risk of node.risks || []) lines.push(` - 风险: ${risk}`) + } + if (graph.edges.length) { + lines.push('') + lines.push('### 字段关系') + for (const edge of graph.edges) lines.push(`- \`${edge.from}\` → \`${edge.to}\`: ${edge.relation}`) + } + if (graph.liveContext?.findings?.length) { + lines.push('') + lines.push('### 当前实例相关风险') + for (const f of graph.liveContext.findings.slice(0, 8)) lines.push(`- [${f.level}] ${f.component}: ${f.title} — ${f.evidence}`) + } + lines.push('') + lines.push('使用建议:回答 OpenClaw 配置问题时,先确认字段路径,再结合当前实例事实给出修复建议;不要输出 API Key 明文。') + return lines.join('\n') +} + +async function openClawSchemaGraphTool(args = {}) { + const graph = await collectOpenClawSchemaGraph(args) + if (args.format === 'json') return JSON.stringify(graph, null, 2) + return formatOpenClawSchemaGraph(graph) +} + +function shellArg(value) { + const s = String(value ?? '').replace(/[\r\n]/g, ' ') + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(s)) return s + return `"${s.replace(/(["\\$`])/g, '\\$1').replace(/%/g, '%%')}"` +} + +function safeBrowserSessionName(value) { + const raw = String(value || 'clawpanel-assistant').trim() + return raw.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 60) || 'clawpanel-assistant' +} + +function safeBrowserFilename(value) { + const raw = String(value || '').trim() + if (!raw) return '' + return raw.replace(/[<>:"|?*\r\n\\/]+/g, '_').slice(0, 120) +} + +function validateBrowserUrl(url, required) { + const text = String(url || '').trim() + if (!text && !required) return '' + if (!text) throw new Error('缺少 URL') + const u = new URL(text) + if (!['http:', 'https:'].includes(u.protocol)) throw new Error('browser_action 只允许 http/https URL') + return u.toString() +} + +function browserCliMissing(output) { + return /not recognized|command not found|not found|could not determine executable|could not be found|ENOENT/i.test(String(output || '')) +} + +function buildBrowserCliArgs(args) { + const action = String(args.action || '').trim() + if (!BROWSER_READ_ACTIONS.has(action) && !BROWSER_WRITE_ACTIONS.has(action)) throw new Error(`不支持的浏览器动作: ${action}`) + if (MODES[currentMode()]?.readOnly && BROWSER_WRITE_ACTIONS.has(action)) throw new Error('规划模式只允许浏览器只读动作,请切换到执行模式后再点击、填表或按键。') + const parts = [`-s=${safeBrowserSessionName(args.session)}`, action] + if (action === 'open') { + if (args.browser) parts.push(`--browser=${String(args.browser).trim()}`) + const url = validateBrowserUrl(args.url, false) + if (url) parts.push(url) + } else if (action === 'goto') { + parts.push(validateBrowserUrl(args.url, true)) + } else if (action === 'snapshot') { + if (args.ref) parts.push(String(args.ref)) + if (Number.isInteger(args.depth) && args.depth > 0) parts.push(`--depth=${Math.min(args.depth, 8)}`) + const filename = safeBrowserFilename(args.filename) + if (filename) parts.push(`--filename=${filename}`) + } else if (action === 'click') { + if (!args.ref) throw new Error('click 需要 ref') + parts.push(String(args.ref)) + if (args.button) parts.push(String(args.button)) + } else if (action === 'fill') { + if (!args.ref) throw new Error('fill 需要 ref') + parts.push(String(args.ref), String(args.text || '')) + if (args.submit === true) parts.push('--submit') + } else if (action === 'type') { + if (!args.text) throw new Error('type 需要 text') + parts.push(String(args.text)) + } else if (action === 'press') { + if (!args.key) throw new Error('press 需要 key') + parts.push(String(args.key)) + } else if (action === 'screenshot') { + if (args.ref) parts.push(String(args.ref)) + const filename = safeBrowserFilename(args.filename) + if (filename) parts.push(`--filename=${filename}`) + } + return parts +} + +async function runPlaywrightCli(parts) { + const cmd = ['playwright-cli', ...parts.map(shellArg)].join(' ') + let output = await api.assistantExec(cmd) + if (!browserCliMissing(output)) return output + const npxCmd = ['npx', '--no-install', 'playwright-cli', ...parts.map(shellArg)].join(' ') + output = await api.assistantExec(npxCmd) + if (!browserCliMissing(output)) return output + return `未检测到 Playwright CLI。\n\n可安装后重试:\n\`\`\`\nnpm install -g @playwright/cli@latest\n\`\`\`\n\n原始输出:\n${output}` +} + +async function browserActionTool(args = {}) { + const parts = buildBrowserCliArgs(args) + const output = await runPlaywrightCli(parts) + return `## Browser Action: ${args.action}\n\n${output}` +} + // ── 状态 ── let _page = null, _messagesEl = null, _textarea = null, _sendBtn = null let _sessionListEl = null, _settingsPanel = null, _queueEl = null @@ -1824,7 +2504,7 @@ async function _callAIOnce(messages, onChunk) { const base = cleanBaseUrl(_config.baseUrl, apiType) _abortController = new AbortController() - const allMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages] + const allMessages = [{ role: 'system', content: await buildSystemPromptAsync() }, ...messages] // 总超时保护 let _timedOut = false @@ -2223,6 +2903,14 @@ async function executeTool(name, args) { return await api.assistantListProcesses(args.filter) case 'check_port': return await api.assistantCheckPort(args.port) + case 'get_openclaw_context': + return await openClawContextTool(args) + case 'diagnose_openclaw': + return await diagnoseOpenClawTool(args) + case 'get_openclaw_schema_graph': + return await openClawSchemaGraphTool(args) + case 'browser_action': + return await browserActionTool(args) case 'ask_user': return await showAskUserCard(args) case 'web_search': @@ -2362,6 +3050,8 @@ async function confirmToolCall(tc, critical = false) { } else if (name === 'write_file') { const preview = (args.content || '').slice(0, 200) desc = `${t('assistant.confirmWriteFile')}:\n${args.path}\n\n${t('assistant.confirmPreview')}:\n${preview}${(args.content || '').length > 200 ? '\n...(' + t('assistant.confirmTruncated') + ')' : ''}` + } else if (name === 'browser_action') { + desc = `浏览器动作: ${args.action || ''}\nURL: ${args.url || '-'}\n目标: ${args.ref || '-'}\n会话: ${args.session || 'clawpanel-assistant'}` } const prefix = critical @@ -2398,9 +3088,13 @@ async function executeToolWithSafety(toolName, args, tcForConfirm) { let result = '', approved = true const mode = MODES[currentMode()] const isCritical = toolName === 'run_command' && isCriticalCommand(args.command) + const isBrowserWrite = toolName === 'browser_action' && BROWSER_WRITE_ACTIONS.has(String(args.action || '')) if (isCritical) { approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }, true) if (!approved) result = t('assistant.toolRejectedDanger') + } else if (isBrowserWrite) { + approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }) + if (!approved) result = t('assistant.toolRejected') } else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) { approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }) if (!approved) result = t('assistant.toolRejected') @@ -2421,7 +3115,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress, onChunk) { const base = cleanBaseUrl(_config.baseUrl, apiType) const tools = getEnabledTools() - let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages] + let currentMessages = [{ role: 'system', content: await buildSystemPromptAsync() }, ...messages] const toolHistory = [] const autoRounds = _config.autoRounds ?? 8 // 0 = 无限制 @@ -2727,19 +3421,31 @@ function renderSessionList() { }).join('') || '
' + t('assistant.noSessions') + '
' } +function argsStrFromObject(args, keys) { + return keys.map(key => { + const value = args?.[key] + if (value === undefined || value === null || value === '') return '' + return `${key}=${String(value)}` + }).filter(Boolean).join(' ') +} + function renderToolBlocks(toolHistory) { if (!toolHistory || toolHistory.length === 0) return '' return toolHistory.map(tc => { // ask_user 工具不显示在工具块中(它有自己的交互卡片) if (tc.name === 'ask_user') return '' - const tcIcon = { run_command: icon('terminal', 14), write_file: icon('edit', 14), read_file: icon('file', 14), list_directory: icon('folder', 14), get_system_info: icon('monitor', 14), list_processes: icon('list', 14), check_port: icon('plug', 14), skills_list: icon('box', 14), skills_info: icon('box', 14), skills_check: icon('box', 14), skills_install_dep: icon('download', 14), skillhub_search: icon('search', 14), skillhub_install: icon('download', 14) }[tc.name] || icon('wrench', 14) - const label = { run_command: t('assistant.toolRunCmd'), read_file: t('assistant.toolReadFile'), write_file: t('assistant.toolWriteFile'), list_directory: t('assistant.toolListDir'), get_system_info: t('assistant.toolSysInfo'), list_processes: t('assistant.toolProcessList'), check_port: t('assistant.toolCheckPort'), skills_list: t('assistant.toolSkillsList'), skills_info: t('assistant.toolSkillInfo'), skills_check: t('assistant.toolSkillsCheck'), skills_install_dep: t('assistant.toolInstallDep'), skillhub_search: t('assistant.toolSkillHubSearch'), skillhub_install: t('assistant.toolSkillHubInstall') }[tc.name] || tc.name + const tcIcon = { run_command: icon('terminal', 14), write_file: icon('edit', 14), read_file: icon('file', 14), list_directory: icon('folder', 14), get_system_info: icon('monitor', 14), list_processes: icon('list', 14), check_port: icon('plug', 14), get_openclaw_context: icon('info', 14), diagnose_openclaw: icon('shield', 14), get_openclaw_schema_graph: icon('hash', 14), browser_action: icon('globe', 14), skills_list: icon('box', 14), skills_info: icon('box', 14), skills_check: icon('box', 14), skills_install_dep: icon('download', 14), skillhub_search: icon('search', 14), skillhub_install: icon('download', 14) }[tc.name] || icon('wrench', 14) + const label = { run_command: t('assistant.toolRunCmd'), read_file: t('assistant.toolReadFile'), write_file: t('assistant.toolWriteFile'), list_directory: t('assistant.toolListDir'), get_system_info: t('assistant.toolSysInfo'), list_processes: t('assistant.toolProcessList'), check_port: t('assistant.toolCheckPort'), get_openclaw_context: '读取 OpenClaw 实况', diagnose_openclaw: '诊断 OpenClaw', get_openclaw_schema_graph: 'OpenClaw Schema 图谱', browser_action: '浏览器操作', skills_list: t('assistant.toolSkillsList'), skills_info: t('assistant.toolSkillInfo'), skills_check: t('assistant.toolSkillsCheck'), skills_install_dep: t('assistant.toolInstallDep'), skillhub_search: t('assistant.toolSkillHubSearch'), skillhub_install: t('assistant.toolSkillHubInstall') }[tc.name] || tc.name const argsStr = tc.name === 'run_command' ? escHtml(tc.args.command || '') : tc.name === 'read_file' ? escHtml(tc.args.path || '') : tc.name === 'write_file' ? escHtml(tc.args.path || '') : tc.name === 'list_directory' ? escHtml(tc.args.path || '') : tc.name === 'get_system_info' ? '' + : tc.name === 'get_openclaw_context' ? escHtml(argsStrFromObject(tc.args, ['format', 'refresh'])) + : tc.name === 'diagnose_openclaw' ? escHtml(argsStrFromObject(tc.args, ['component', 'deep'])) + : tc.name === 'get_openclaw_schema_graph' ? escHtml(argsStrFromObject(tc.args, ['path', 'format', 'includeLiveContext'])) + : tc.name === 'browser_action' ? escHtml(argsStrFromObject(tc.args, ['action', 'url', 'ref', 'session'])) : tc.name === 'list_processes' ? escHtml(tc.args.filter || t('assistant.toolFilterAll')) : tc.name === 'check_port' ? escHtml(String(tc.args.port || '')) : tc.name === 'skills_info' ? escHtml(tc.args.name || '') diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 371f277..da9415a 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -10,6 +10,7 @@ import { navigate } from '../router.js' import { t } from '../lib/i18n.js' import { wsClient } from '../lib/ws-client.js' import { attachCliConflictBanner } from '../components/cli-conflict-banner.js' +import { icon } from '../lib/icons.js' let _unsubGw = null let _loadInFlight = false @@ -40,7 +41,7 @@ export async function render() { - +
${t('dashboard.recentLogs')}
@@ -557,18 +558,18 @@ function renderWsStatus() {
` } -const CHANNEL_ICONS = { qqbot: '🐧', qq: '🐧', feishu: '🪶', dingtalk: '📌', telegram: '✈️', discord: '🎮', slack: '💬', weixin: '💚', wechat: '💚', webchat: '🌐', whatsapp: '📱', line: '🟢', teams: '👥', matrix: '🔗' } +const CHANNEL_ICONS = { qqbot: 'message-square', qq: 'message-circle', feishu: 'message-square', dingtalk: 'message-square', telegram: 'send', discord: 'hash', slack: 'hash', weixin: 'message-circle', wechat: 'message-circle', webchat: 'globe', whatsapp: 'phone', line: 'message-circle', teams: 'users', msteams: 'users', matrix: 'globe' } function renderChannelsOverview(channels) { if (!channels || channels.length === 0) return '' const items = channels.map(ch => { - const icon = CHANNEL_ICONS[ch.platform] || '📡' + const channelIcon = icon(CHANNEL_ICONS[ch.platform] || 'radio', 14) const enabled = ch.enabled !== false const dot = enabled ? 'var(--success)' : 'var(--text-tertiary)' const name = ch.name || ch.platform || ch.id || '' return ` - ${icon} ${escapeHtml(name)} + ${channelIcon} ${escapeHtml(name)} ` }) return ` diff --git a/src/pages/models.js b/src/pages/models.js index 355687d..96af07d 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -29,6 +29,7 @@ export async function render() {
+
@@ -1097,6 +1098,7 @@ function applyDefaultModel(state) { // 顶部按钮事件 function bindTopActions(page, state) { page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state) + page.querySelector('#btn-import-client').onclick = () => importClientConfigs(page, state) page.querySelector('#btn-undo').onclick = () => undo(page, state) // 晴辰云:获取模型列表 → 弹窗让用户选择要添加的模型 @@ -1214,6 +1216,121 @@ function bindTopActions(page, state) { } } +function uniqueProviderKey(providers, desired) { + const base = (desired || 'imported').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'imported' + if (!providers[base]) return base + let i = 2 + while (providers[`${base}-${i}`]) i++ + return `${base}-${i}` +} + +function candidateModels(candidate) { + const ids = Array.isArray(candidate.models) ? candidate.models.filter(Boolean) : [] + return [...new Set(ids)].map(id => ({ id, name: id })) +} + +async function importClientConfigs(page, state) { + if (!state.config) { toast(t('models.configNotReady'), 'warning'); return } + const btn = page.querySelector('#btn-import-client') + const oldText = btn?.textContent + if (btn) { btn.disabled = true; btn.textContent = t('models.importScanning') } + let candidates = [] + try { + const result = await api.scanModelClientConfigs() + candidates = Array.isArray(result?.candidates) ? result.candidates : [] + } catch (e) { + toast(`${t('models.importScanFailed')}: ${humanizeError(e)}`, 'error') + return + } finally { + if (btn) { btn.disabled = false; btn.textContent = oldText || t('models.importClientConfigs') } + } + if (!candidates.length) { + toast(t('models.importNoneFound'), 'info') + return + } + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + document.body.appendChild(overlay) + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) + overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() + overlay.querySelector('[data-action="import"]').onclick = () => { + const selected = [...overlay.querySelectorAll('input[type="checkbox"]:checked')] + .map(input => candidates[Number(input.dataset.index)]) + .filter(Boolean) + if (!selected.length) { toast(t('models.importNoneSelected'), 'warning'); return } + pushUndo(state) + if (!state.config.models) state.config.models = { mode: 'replace', providers: {} } + if (!state.config.models.providers) state.config.models.providers = {} + const providers = state.config.models.providers + let imported = 0 + let firstFull = '' + for (const candidate of selected) { + const models = candidateModels(candidate) + if (!models.length) continue + const key = uniqueProviderKey(providers, candidate.providerKey || candidate.id) + providers[key] = { + baseUrl: candidate.baseUrl || '', + apiKey: candidate.apiKey || '', + api: candidate.api || 'openai-completions', + models, + } + if (!firstFull && candidate.apiKeyStatus !== 'missing') firstFull = `${key}/${models[0].id}` + imported++ + } + if (!imported) { toast(t('models.importNoImportable'), 'warning'); return } + if (!getCurrentPrimary(state.config) && firstFull) { + ensureDefaultModelConfig(state).primary = firstFull + } + overlay.remove() + renderProviders(page, state) + renderDefaultBar(page, state) + updateUndoBtn(page, state) + autoSave(state) + toast(t('models.importDone', { count: imported }), 'success') + } +} + // 添加服务商(带预设快捷选择) function addProvider(page, state) { // 构建预设按钮 HTML