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:
zhixian
2026-02-19 20:21:21 +09:00
parent 66c259fffb
commit fdbfe0936f
5 changed files with 139 additions and 9 deletions

View File

@@ -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, &current_text, &cfg, "set-agent-model")
.await?;
Ok(true)
}
#[tauri::command]
pub async fn remote_run_doctor(
pool: State<'_, SshConnectionPool>,

View File

@@ -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,

View File

@@ -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> =>

View File

@@ -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 = () => {

View File

@@ -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 ? (