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 <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -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<String>,
|
||||
) -> Result<bool, String> {
|
||||
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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -94,6 +94,8 @@ export const api = {
|
||||
invoke("restart_gateway", {}),
|
||||
setGlobalModel: (profileId: string | null): Promise<boolean> =>
|
||||
invoke("set_global_model", { profileId }),
|
||||
setAgentModel: (agentId: string, profileId: string | null): Promise<boolean> =>
|
||||
invoke("set_agent_model", { agentId, profileId }),
|
||||
listBindings: (): Promise<Binding[]> =>
|
||||
invoke("list_bindings", {}),
|
||||
assignChannelAgent: (channelType: string, peerId: string, agentId: string | null): Promise<boolean> =>
|
||||
@@ -158,6 +160,8 @@ export const api = {
|
||||
invoke("remote_assign_channel_agent", { hostId, channelType, peerId, agentId }),
|
||||
remoteSetGlobalModel: (hostId: string, modelValue: string | null): Promise<boolean> =>
|
||||
invoke("remote_set_global_model", { hostId, modelValue }),
|
||||
remoteSetAgentModel: (hostId: string, agentId: string, modelValue: string | null): Promise<boolean> =>
|
||||
invoke("remote_set_agent_model", { hostId, agentId, modelValue }),
|
||||
remoteListDiscordGuildChannels: (hostId: string): Promise<DiscordGuildChannel[]> =>
|
||||
invoke("remote_list_discord_guild_channels", { hostId }),
|
||||
remoteRunDoctor: (hostId: string): Promise<DoctorReport> =>
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>)[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 <div>Recipe not found</div>;
|
||||
|
||||
const handleNext = () => {
|
||||
|
||||
@@ -415,9 +415,45 @@ export function Home({
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<code className="text-sm text-foreground font-medium">{agent.id}</code>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{agent.model || "default model"}
|
||||
</span>
|
||||
<Select
|
||||
value={(() => {
|
||||
if (!agent.model) return "__none__";
|
||||
const normalized = agent.model.toLowerCase();
|
||||
for (const p of modelProfiles) {
|
||||
const profileVal = p.model.includes("/") ? p.model : `${p.provider}/${p.model}`;
|
||||
if (profileVal.toLowerCase() === normalized || p.model.toLowerCase() === normalized) {
|
||||
return p.id;
|
||||
}
|
||||
}
|
||||
return "__none__";
|
||||
})()}
|
||||
onValueChange={(val) => {
|
||||
const setModelPromise = isRemote
|
||||
? (() => {
|
||||
const profile = modelProfiles.find((p) => p.id === val);
|
||||
const modelValue = profile ? `${profile.provider}/${profile.model}` : null;
|
||||
return api.remoteSetAgentModel(instanceId, agent.id, modelValue);
|
||||
})()
|
||||
: api.setAgentModel(agent.id, val === "__none__" ? null : val);
|
||||
setModelPromise
|
||||
.then(() => refreshAgents())
|
||||
.catch((e) => showToast?.(String(e), "error"));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="text-xs h-6 w-auto min-w-[120px] max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
<span className="text-muted-foreground">default model</span>
|
||||
</SelectItem>
|
||||
{modelProfiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.provider}/{p.model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{agent.online ? (
|
||||
|
||||
Reference in New Issue
Block a user