From fdbfe0936fdc907647e5a46ae9a8cff7c55cb237 Mon Sep 17 00:00:00 2001 From: zhixian Date: Thu, 19 Feb 2026 20:21:21 +0900 Subject: [PATCH] feat: per-agent model switching on Home page, fix recipe persona pre-fill - Add per-agent model Select dropdown in Home page agent cards - Register set_agent_model command and add remote_set_agent_model for SSH - Add setAgentModel/remoteSetAgentModel API bindings - Fix set_agent_model_value to preserve object model format (update primary) - Pre-populate recipe textarea fields from existing config values (Cook.tsx) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src-tauri/src/commands.rs | 41 ++++++++++++++++++++++++---- src-tauri/src/lib.rs | 5 +++- src/lib/api.ts | 4 +++ src/pages/Cook.tsx | 56 +++++++++++++++++++++++++++++++++++++++ src/pages/Home.tsx | 42 ++++++++++++++++++++++++++--- 5 files changed, 139 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 493ecd6..d507e10 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2924,12 +2924,22 @@ fn set_agent_model_value( if let Some(list) = agents.get_mut("list").and_then(Value::as_array_mut) { for agent in list { if agent.get("id").and_then(Value::as_str) == Some(agent_id) { - if let Some(v) = model { - if let Some(agent_obj) = agent.as_object_mut() { - agent_obj.insert("model".into(), Value::String(v)); + if let Some(agent_obj) = agent.as_object_mut() { + match model { + Some(v) => { + // If existing model is an object, update "primary" inside it + if let Some(existing) = agent_obj.get_mut("model") { + if let Some(model_obj) = existing.as_object_mut() { + model_obj.insert("primary".into(), Value::String(v)); + return Ok(()); + } + } + agent_obj.insert("model".into(), Value::String(v)); + } + None => { + agent_obj.remove("model"); + } } - } else if let Some(agent_obj) = agent.as_object_mut() { - agent_obj.remove("model"); } return Ok(()); } @@ -4803,6 +4813,27 @@ pub async fn remote_set_global_model( Ok(true) } +#[tauri::command] +pub async fn remote_set_agent_model( + pool: State<'_, SshConnectionPool>, + host_id: String, + agent_id: String, + model_value: Option, +) -> Result { + if agent_id.trim().is_empty() { + return Err("agent id is required".into()); + } + let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?; + let mut cfg: Value = + serde_json::from_str(&raw).map_err(|e| format!("Failed to parse: {e}"))?; + let current_text = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; + + set_agent_model_value(&mut cfg, &agent_id, model_value)?; + remote_write_config_with_snapshot(&pool, &host_id, ¤t_text, &cfg, "set-agent-model") + .await?; + Ok(true) +} + #[tauri::command] pub async fn remote_run_doctor( pool: State<'_, SshConnectionPool>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30b22bf..be20c4d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ use crate::commands::{ refresh_discord_guild_channels, restart_gateway, set_global_model, + set_agent_model, list_bindings, assign_channel_agent, save_config_baseline, check_config_dirty, discard_config_changes, apply_pending_changes, @@ -23,7 +24,7 @@ use crate::commands::{ remote_list_agents_overview, remote_list_channels_minimal, remote_list_bindings, remote_restart_gateway, remote_apply_config_patch, remote_create_agent, remote_delete_agent, - remote_assign_channel_agent, remote_set_global_model, + remote_assign_channel_agent, remote_set_global_model, remote_set_agent_model, remote_run_doctor, remote_list_history, remote_list_discord_guild_channels, remote_write_raw_config, remote_analyze_sessions, remote_delete_sessions_by_ids, remote_list_session_files, remote_clear_all_sessions, remote_preview_session, @@ -99,6 +100,7 @@ pub fn run() { refresh_discord_guild_channels, restart_gateway, set_global_model, + set_agent_model, list_bindings, assign_channel_agent, save_config_baseline, @@ -127,6 +129,7 @@ pub fn run() { remote_delete_agent, remote_assign_channel_agent, remote_set_global_model, + remote_set_agent_model, remote_run_doctor, remote_list_history, remote_list_discord_guild_channels, diff --git a/src/lib/api.ts b/src/lib/api.ts index 3996690..3a90206 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -94,6 +94,8 @@ export const api = { invoke("restart_gateway", {}), setGlobalModel: (profileId: string | null): Promise => invoke("set_global_model", { profileId }), + setAgentModel: (agentId: string, profileId: string | null): Promise => + invoke("set_agent_model", { agentId, profileId }), listBindings: (): Promise => invoke("list_bindings", {}), assignChannelAgent: (channelType: string, peerId: string, agentId: string | null): Promise => @@ -158,6 +160,8 @@ export const api = { invoke("remote_assign_channel_agent", { hostId, channelType, peerId, agentId }), remoteSetGlobalModel: (hostId: string, modelValue: string | null): Promise => invoke("remote_set_global_model", { hostId, modelValue }), + remoteSetAgentModel: (hostId: string, agentId: string, modelValue: string | null): Promise => + invoke("remote_set_agent_model", { hostId, agentId, modelValue }), remoteListDiscordGuildChannels: (hostId: string): Promise => invoke("remote_list_discord_guild_channels", { hostId }), remoteRunDoctor: (hostId: string): Promise => diff --git a/src/pages/Cook.tsx b/src/pages/Cook.tsx index ab65474..34128e9 100644 --- a/src/pages/Cook.tsx +++ b/src/pages/Cook.tsx @@ -43,6 +43,62 @@ export function Cook({ }); }, [recipeId, recipeSource]); + // Pre-populate fields from existing config when channel is selected + useEffect(() => { + if (!recipe) return; + const guildId = params.guild_id; + const channelId = params.channel_id; + if (!guildId || !channelId) return; + + // Find textarea params that map to config values via the recipe steps + const configPaths: Record = {}; + for (const step of recipe.steps) { + if (step.action !== "config_patch" || typeof step.args?.patchTemplate !== "string") continue; + try { + const tpl = (step.args.patchTemplate as string) + .replace(/\{\{guild_id\}\}/g, guildId) + .replace(/\{\{channel_id\}\}/g, channelId); + const parsed = JSON.parse(tpl); + // Walk the parsed object to find {{param}} leaves + const walk = (obj: Record, path: string) => { + for (const [k, v] of Object.entries(obj)) { + const full = path ? `${path}.${k}` : k; + if (typeof v === "string") { + const m = v.match(/^\{\{(\w+)\}\}$/); + if (m) configPaths[m[1]] = full; + } else if (v && typeof v === "object") { + walk(v as Record, full); + } + } + }; + walk(parsed, ""); + } catch { /* ignore parse errors */ } + } + + if (Object.keys(configPaths).length === 0) return; + + const readConfig = isRemote + ? api.remoteReadRawConfig(instanceId) + : api.readRawConfig(); + + readConfig.then((raw) => { + try { + const cfg = JSON.parse(raw); + for (const [paramId, path] of Object.entries(configPaths)) { + const parts = path.split("."); + let cur: unknown = cfg; + for (const part of parts) { + if (cur && typeof cur === "object") cur = (cur as Record)[part]; + else { cur = undefined; break; } + } + if (typeof cur === "string" && cur.length > 0) { + setParams((prev) => ({ ...prev, [paramId]: prev[paramId] || cur as string })); + } + } + } catch { /* ignore */ } + }).catch(() => { /* ignore config read errors */ }); + }, [recipe, params.guild_id, params.channel_id, isRemote, instanceId]); + if (!recipe) return
Recipe not found
; const handleNext = () => { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 24bae84..acaed65 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -415,9 +415,45 @@ export function Home({ >
{agent.id} - - {agent.model || "default model"} - +
{agent.online ? (