diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 0528a2e49..cdb0f3e39 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -482,6 +482,10 @@ pub fn parse_and_merge_config( "claude" => merge_claude_config(&mut merged, &config_value)?, "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, + // Additive mode apps use JSON config directly; pass through as-is + "openclaw" | "opencode" => { + merge_additive_config(&mut merged, &config_value)?; + } "" => { // No app specified, skip merging return Ok(merged); @@ -653,6 +657,47 @@ fn merge_gemini_config( Ok(()) } +/// Merge configuration for additive mode apps (OpenClaw, OpenCode) +/// +/// These apps use JSON config directly, so we only extract common fields +/// (api_key, endpoint, model) from the config if not already set in URL params. +fn merge_additive_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + // Extract api_key from config if not provided in URL + if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(api_key) = config + .get("apiKey") + .or_else(|| config.get("api_key")) + .and_then(|v| v.as_str()) + { + request.api_key = Some(api_key.to_string()); + } + } + + // Extract endpoint from config if not provided in URL + if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(base_url) = config + .get("baseUrl") + .or_else(|| config.get("base_url")) + .or_else(|| config.get("options").and_then(|o| o.get("baseURL"))) + .and_then(|v| v.as_str()) + { + request.endpoint = Some(base_url.to_string()); + } + } + + // Auto-fill homepage from endpoint + if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { + request.homepage = infer_homepage_from_endpoint(endpoint); + } + } + + Ok(()) +} + /// Extract base_url from Codex TOML config fn extract_codex_base_url(toml_value: &toml::Value) -> Option { // Try to find base_url in model_providers section diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 30a51ef3f..20b18ccf9 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -95,9 +95,9 @@ pub struct OpenClawProviderConfig { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, - /// 其他自定义字段(保留原始配置) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub extra: Map, + pub extra: HashMap, } /// OpenClaw 模型条目 @@ -123,9 +123,9 @@ pub struct OpenClawModelEntry { #[serde(skip_serializing_if = "Option::is_none")] pub context_window: Option, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub extra: Map, + pub extra: HashMap, } /// OpenClaw 模型成本配置 @@ -137,9 +137,9 @@ pub struct OpenClawModelCost { /// 输出价格(每百万 token) pub output: f64, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// OpenClaw 默认模型配置(agents.defaults.model) @@ -152,9 +152,9 @@ pub struct OpenClawDefaultModel { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fallbacks: Vec, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// OpenClaw 模型目录条目(agents.defaults.models 中的值) @@ -164,9 +164,9 @@ pub struct OpenClawModelCatalogEntry { #[serde(skip_serializing_if = "Option::is_none")] pub alias: Option, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// OpenClaw agents.defaults 配置 @@ -180,21 +180,22 @@ pub struct OpenClawAgentsDefaults { #[serde(skip_serializing_if = "Option::is_none")] pub models: Option>, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// OpenClaw agents 顶层配置 #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct OpenClawAgents { /// 默认配置 #[serde(skip_serializing_if = "Option::is_none")] pub defaults: Option, - /// 其他自定义字段(保留未知字段) + /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } // ============================================================================ @@ -513,7 +514,7 @@ pub struct OpenClawToolsConfig { /// Other custom fields (preserve unknown fields) #[serde(flatten)] - pub other: HashMap, + pub extra: HashMap, } /// Read the tools config section @@ -525,7 +526,7 @@ pub fn get_tools_config() -> Result { profile: None, allow: Vec::new(), deny: Vec::new(), - other: HashMap::new(), + extra: HashMap::new(), }); }; diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 2bd279a60..da4e15fa6 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -478,40 +478,9 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { - // OpenCode uses additive mode - import from live is not the same pattern - // For now, return an empty config structure - use crate::opencode_config::{get_opencode_config_path, read_opencode_config}; - - let config_path = get_opencode_config_path(); - if !config_path.exists() { - return Err(AppError::localized( - "opencode.live.missing", - "OpenCode 配置文件不存在", - "OpenCode configuration file is missing", - )); - } - - // For OpenCode, we return the full config - but note that OpenCode - // uses additive mode, so importing defaults works differently - read_opencode_config()? - } - AppType::OpenClaw => { - // OpenClaw uses additive mode - import from live is not the same pattern - use crate::openclaw_config::{get_openclaw_config_path, read_openclaw_config}; - - let config_path = get_openclaw_config_path(); - if !config_path.exists() { - return Err(AppError::localized( - "openclaw.live.missing", - "OpenClaw 配置文件不存在", - "OpenClaw configuration file is missing", - )); - } - - // For OpenClaw, we return the full config - but note that OpenClaw - // uses additive mode, so importing defaults works differently - read_openclaw_config()? + // OpenCode and OpenClaw use additive mode and are handled by early return above + AppType::OpenCode | AppType::OpenClaw => { + unreachable!("additive mode apps are handled by early return") } }; @@ -762,6 +731,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result Result<(), AppError> { use crate::openclaw_config; + // Check if OpenClaw config directory exists + if !openclaw_config::get_openclaw_dir().exists() { + log::debug!("OpenClaw config directory doesn't exist, skipping removal of '{provider_id}'"); + return Ok(()); + } + openclaw_config::remove_provider(provider_id)?; log::info!("OpenClaw provider '{provider_id}' removed from live config"); diff --git a/src/App.tsx b/src/App.tsx index 384eaa661..1eedef4da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -970,7 +970,7 @@ function App() { )} {currentView === "providers" && ( <> - {activeApp !== "opencode" && ( + {activeApp !== "opencode" && activeApp !== "openclaw" && ( <>
{ useEffect(() => { if (envData) { const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({ + id: crypto.randomUUID(), key, value: String(value ?? ""), })); @@ -34,9 +36,15 @@ const EnvPanel: React.FC = () => { const handleSave = async () => { try { const env: OpenClawEnvConfig = {}; + const seen = new Set(); for (const entry of entries) { const trimmedKey = entry.key.trim(); if (trimmedKey) { + if (seen.has(trimmedKey)) { + toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey })); + return; + } + seen.add(trimmedKey); env[trimmedKey] = entry.value; } } @@ -51,7 +59,10 @@ const EnvPanel: React.FC = () => { }; const addEntry = () => { - setEntries((prev) => [...prev, { key: "", value: "", isNew: true }]); + setEntries((prev) => [ + ...prev, + { id: crypto.randomUUID(), key: "", value: "", isNew: true }, + ]); }; const removeEntry = (index: number) => { @@ -103,7 +114,7 @@ const EnvPanel: React.FC = () => { const visible = visibleKeys.has(visibilityId); return ( -
+
{ @@ -23,14 +28,24 @@ const ToolsPanel: React.FC = () => { const { data: toolsData, isLoading } = useOpenClawTools(); const saveToolsMutation = useSaveOpenClawTools(); const [config, setConfig] = useState({}); - const [allowList, setAllowList] = useState([]); - const [denyList, setDenyList] = useState([]); + const [allowList, setAllowList] = useState([]); + const [denyList, setDenyList] = useState([]); useEffect(() => { if (toolsData) { setConfig(toolsData); - setAllowList(toolsData.allow ?? []); - setDenyList(toolsData.deny ?? []); + setAllowList( + (toolsData.allow ?? []).map((v) => ({ + id: crypto.randomUUID(), + value: v, + })), + ); + setDenyList( + (toolsData.deny ?? []).map((v) => ({ + id: crypto.randomUUID(), + value: v, + })), + ); } }, [toolsData]); @@ -40,8 +55,8 @@ const ToolsPanel: React.FC = () => { const newConfig: OpenClawToolsConfig = { ...other, profile: config.profile, - allow: allowList.filter((s) => s.trim()), - deny: denyList.filter((s) => s.trim()), + allow: allowList.map((item) => item.value).filter((s) => s.trim()), + deny: denyList.map((item) => item.value).filter((s) => s.trim()), }; await saveToolsMutation.mutateAsync(newConfig); toast.success(t("openclaw.tools.saveSuccess")); @@ -54,20 +69,20 @@ const ToolsPanel: React.FC = () => { }; const updateListItem = ( - list: string[], - setList: React.Dispatch>, + setList: React.Dispatch>, index: number, value: string, ) => { - setList(list.map((item, i) => (i === index ? value : item))); + setList((prev) => + prev.map((item, i) => (i === index ? { ...item, value } : item)), + ); }; const removeListItem = ( - list: string[], - setList: React.Dispatch>, + setList: React.Dispatch>, index: number, ) => { - setList(list.filter((_, i) => i !== index)); + setList((prev) => prev.filter((_, i) => i !== index)); }; if (isLoading) { @@ -113,11 +128,11 @@ const ToolsPanel: React.FC = () => {
{allowList.map((item, index) => ( -
+
- updateListItem(allowList, setAllowList, index, e.target.value) + updateListItem(setAllowList, index, e.target.value) } placeholder={t("openclaw.tools.patternPlaceholder")} className="font-mono text-xs" @@ -126,7 +141,7 @@ const ToolsPanel: React.FC = () => { variant="ghost" size="icon" className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive" - onClick={() => removeListItem(allowList, setAllowList, index)} + onClick={() => removeListItem(setAllowList, index)} > @@ -135,7 +150,12 @@ const ToolsPanel: React.FC = () => { @@ -170,7 +190,12 @@ const ToolsPanel: React.FC = () => {