fix(canvas+templates): fetch runtime dropdown from /templates registry (#1526)
* fix(canvas+templates): fetch runtime dropdown from /templates registry
Canvas hardcoded 6 runtime options, drifting from manifest.json which
already registers hermes + gemini-cli as first-class workspace templates.
A Hermes workspace had runtime=hermes in its DB row but Config showed
"LangGraph (default)" — the HTML select fell back to its first option
because "hermes" wasn't listed, and saving would clobber the runtime
back to empty.
Now:
- GET /templates returns the runtime field from each cloned template's
config.yaml (previously dropped on the floor)
- ConfigTab fetches /templates on mount, dedupes non-empty runtimes, and
renders them as <option>s. Falls back to the static list if the fetch
fails (offline, older backend), so the control never renders empty.
Adding a template to manifest.json now flows through automatically — no
canvas PR required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(canvas+templates): model + required-env suggestions from template
Extends the dropdown fix so Model and Required Env also flow from
the template registry instead of being free-form fields the user
has to remember.
Template config.yaml now declares:
runtime_config:
model: <default>
models:
- id: nous-hermes-3-70b
name: Nous Hermes 3 70B (Nous Portal)
required_env: [HERMES_API_KEY]
- id: nousresearch/hermes-3-llama-3.1-70b
name: Hermes 3 70B (via OpenRouter)
required_env: [OPENROUTER_API_KEY]
Platform: GET /templates now returns runtime + model + models[] per
template (was previously dropping runtime + ignoring runtime_config).
Canvas:
- Runtime dropdown built from /templates (was hardcoded 6 options)
- Model input becomes a datalist combobox; free-form input still
allowed since model names rotate faster than templates
- Required Env Vars default to the selected model's required_env,
labelled "(suggested)" so the user knows it's template-driven
- Everything falls back to a static list when /templates is
unreachable, so offline editing still works
Follow-up: add models[] to the other 7 template repos (claude-code,
crewai, autogen, deepagents, openclaw, gemini-cli, langgraph). This
PR updates the platform + canvas; the Hermes template config update
goes in a separate PR against its own repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(canvas): commit required_env on model change; add backend tests
Review turned up that the \"Required Env Vars (suggested)\" display
was cosmetic-only — users picking a different model saw the new
env suggestion in the TagList, but the values never made it into
state, so Save serialized an empty (or stale) required_env and the
workspace ran with the wrong auth check.
Canvas fixes:
- Model input onChange now commits the matched modelSpec's required_env
to state — but only when the prior required_env was empty or matched
the previous modelSpec's list (i.e. user hadn't manually edited).
User-typed envs always win.
- Dropped the display-only fallback in TagList values; shows only what's
actually in state.
- New \"Template suggests X, Apply\" hint button covers the edge case
where state and template differ (existing workspace whose required_env
lags the template's current recommendation).
- datalist option key now includes index so template authors shipping
duplicate model ids don't trigger a silent React key collision.
- Small arraysEqual helper.
Backend tests:
- TestTemplatesList_RuntimeAndModelsRegistry — asserts /templates
response carries runtime + models[] with per-model required_env.
- TestTemplatesList_LegacyTopLevelModel — asserts older templates with
top-level model: still surface correctly, with empty Models[].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
201e18f9ed
commit
359dc615e9
@ -85,6 +85,36 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
|
||||
// --- Main ConfigTab ---
|
||||
|
||||
interface ModelSpec {
|
||||
id: string;
|
||||
name?: string;
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
interface RuntimeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
models: ModelSpec[];
|
||||
}
|
||||
|
||||
// Fallback used when /templates can't be fetched (offline, older backend).
|
||||
// Keep in sync with manifest.json workspace_templates as a defensive default.
|
||||
// Model + env suggestions only flow when the backend is reachable.
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "", label: "LangGraph (default)", models: [] },
|
||||
{ value: "claude-code", label: "Claude Code", models: [] },
|
||||
{ value: "crewai", label: "CrewAI", models: [] },
|
||||
{ value: "autogen", label: "AutoGen", models: [] },
|
||||
{ value: "deepagents", label: "DeepAgents", models: [] },
|
||||
{ value: "openclaw", label: "OpenClaw", models: [] },
|
||||
{ value: "hermes", label: "Hermes", models: [] },
|
||||
{ value: "gemini-cli", label: "Gemini CLI", models: [] },
|
||||
];
|
||||
|
||||
export function ConfigTab({ workspaceId }: Props) {
|
||||
const [config, setConfig] = useState<ConfigData>({ ...DEFAULT_CONFIG });
|
||||
const [originalYaml, setOriginalYaml] = useState("");
|
||||
@ -94,6 +124,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [rawMode, setRawMode] = useState(false);
|
||||
const [rawDraft, setRawDraft] = useState("");
|
||||
const [runtimeOptions, setRuntimeOptions] = useState<RuntimeOption[]>(FALLBACK_RUNTIME_OPTIONS);
|
||||
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
@ -120,6 +151,36 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<Array<{ id: string; name?: string; runtime?: string; models?: ModelSpec[] }>>("/templates")
|
||||
.then((rows) => {
|
||||
if (cancelled || !Array.isArray(rows)) return;
|
||||
const byRuntime = new Map<string, RuntimeOption>();
|
||||
byRuntime.set("", { value: "", label: "LangGraph (default)", models: [] });
|
||||
for (const r of rows) {
|
||||
const v = (r.runtime || "").trim();
|
||||
if (!v || v === "langgraph") continue;
|
||||
// Last template wins if two templates share a runtime — rare, and the
|
||||
// one with the richer models list is probably newer.
|
||||
const existing = byRuntime.get(v);
|
||||
const models = Array.isArray(r.models) ? r.models : [];
|
||||
if (!existing || models.length > existing.models.length) {
|
||||
byRuntime.set(v, { value: v, label: r.name || v, models });
|
||||
}
|
||||
}
|
||||
if (byRuntime.size > 1) setRuntimeOptions(Array.from(byRuntime.values()));
|
||||
})
|
||||
.catch(() => { /* keep fallback */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Models + env hints for the currently-selected runtime.
|
||||
const selectedRuntime = runtimeOptions.find((o) => o.value === (config.runtime || "")) ?? null;
|
||||
const availableModels: ModelSpec[] = selectedRuntime?.models ?? [];
|
||||
const currentModelId = config.runtime_config?.model || config.model || "";
|
||||
const currentModelSpec = availableModels.find((m) => m.id === currentModelId) ?? null;
|
||||
|
||||
const update = <K extends keyof ConfigData>(key: K, value: ConfigData[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
@ -259,23 +320,99 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
onChange={(e) => update("runtime", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">LangGraph (default)</option>
|
||||
<option value="claude-code">Claude Code</option>
|
||||
<option value="crewai">CrewAI</option>
|
||||
<option value="autogen">AutoGen</option>
|
||||
<option value="deepagents">DeepAgents</option>
|
||||
<option value="openclaw">OpenClaw</option>
|
||||
{runtimeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<TextInput label="Model" value={config.runtime_config?.model || config.model || ""} onChange={(v) => {
|
||||
if (config.runtime) {
|
||||
update("runtime_config", { ...config.runtime_config, model: v });
|
||||
} else {
|
||||
update("model", v);
|
||||
}
|
||||
}} placeholder="e.g. anthropic:claude-sonnet-4-6" mono />
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
Model
|
||||
{availableModels.length > 0 && (
|
||||
<span className="ml-1 text-zinc-600">({availableModels.length} suggested)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list={availableModels.length > 0 ? `${runtimeId}-models` : undefined}
|
||||
value={currentModelId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((prev) => {
|
||||
// If the new value exactly matches a known modelSpec id,
|
||||
// swap required_env to that spec's list — but only when
|
||||
// the current required_env is empty or was itself
|
||||
// template-driven (i.e. matches the previous modelSpec's
|
||||
// required_env). User-typed envs always win.
|
||||
const nextSpec = availableModels.find((m) => m.id === v) ?? null;
|
||||
const prevModelId = prev.runtime_config?.model || prev.model || "";
|
||||
const prevSpec = availableModels.find((m) => m.id === prevModelId) ?? null;
|
||||
const prevRequired = prev.runtime_config?.required_env ?? [];
|
||||
const wasTemplateDriven =
|
||||
prevRequired.length === 0 ||
|
||||
(prevSpec?.required_env?.length
|
||||
? prevRequired.length === prevSpec.required_env.length &&
|
||||
prevRequired.every((e, i) => e === prevSpec.required_env![i])
|
||||
: false);
|
||||
const nextRequired =
|
||||
nextSpec?.required_env?.length && wasTemplateDriven
|
||||
? nextSpec.required_env
|
||||
: prevRequired;
|
||||
if (prev.runtime) {
|
||||
return {
|
||||
...prev,
|
||||
runtime_config: {
|
||||
...prev.runtime_config,
|
||||
model: v,
|
||||
...(nextSpec?.required_env?.length && wasTemplateDriven
|
||||
? { required_env: nextRequired }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ...prev, model: v };
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. anthropic:claude-sonnet-4-6"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{availableModels.length > 0 && (
|
||||
<datalist id={`${runtimeId}-models`}>
|
||||
{availableModels.map((m, i) => (
|
||||
<option key={`${m.id}-${i}`} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TagList label="Required Env Vars" values={config.runtime_config?.required_env || []} onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" />
|
||||
<TagList
|
||||
label={
|
||||
currentModelSpec?.required_env?.length &&
|
||||
arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env)
|
||||
? "Required Env Vars (from template)"
|
||||
: "Required Env Vars"
|
||||
}
|
||||
values={config.runtime_config?.required_env ?? []}
|
||||
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
|
||||
placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN"
|
||||
/>
|
||||
{currentModelSpec?.required_env?.length &&
|
||||
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
|
||||
<div className="text-[10px] text-zinc-500 mt-1 flex items-center gap-2">
|
||||
<span>
|
||||
Template suggests{" "}
|
||||
<code className="text-zinc-400">{currentModelSpec.required_env.join(", ")}</code>{" "}
|
||||
for <code className="text-zinc-400">{currentModelSpec.name || currentModelSpec.id}</code>.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateNested("runtime_config" as keyof ConfigData, "required_env", currentModelSpec.required_env)}
|
||||
className="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */}
|
||||
|
||||
@ -36,14 +36,25 @@ func NewTemplatesHandler(configsDir string, dockerCli *client.Client) *Templates
|
||||
return &TemplatesHandler{configsDir: configsDir, docker: dockerCli}
|
||||
}
|
||||
|
||||
// modelSpec describes a single supported model on a template: its id (sent
|
||||
// to the runtime), a human-readable label, and the env vars that must be
|
||||
// present for that model to work (e.g. API keys).
|
||||
type modelSpec struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name"`
|
||||
RequiredEnv []string `json:"required_env,omitempty" yaml:"required_env"`
|
||||
}
|
||||
|
||||
type templateSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tier int `json:"tier"`
|
||||
Model string `json:"model"`
|
||||
Skills []string `json:"skills"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tier int `json:"tier"`
|
||||
Runtime string `json:"runtime"`
|
||||
Model string `json:"model"`
|
||||
Models []modelSpec `json:"models,omitempty"`
|
||||
Skills []string `json:"skills"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
}
|
||||
|
||||
// resolveTemplateDir finds the template directory for a workspace on the host.
|
||||
@ -82,22 +93,35 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tier int `yaml:"tier"`
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tier int `yaml:"tier"`
|
||||
Runtime string `yaml:"runtime"`
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
RuntimeConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Models []modelSpec `yaml:"models"`
|
||||
} `yaml:"runtime_config"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Model comes from either top-level (legacy) or runtime_config.model (current).
|
||||
model := raw.Model
|
||||
if model == "" {
|
||||
model = raw.RuntimeConfig.Model
|
||||
}
|
||||
|
||||
templates = append(templates, templateSummary{
|
||||
ID: entry.Name(),
|
||||
Name: raw.Name,
|
||||
Description: raw.Description,
|
||||
Tier: raw.Tier,
|
||||
Model: raw.Model,
|
||||
Runtime: raw.Runtime,
|
||||
Model: model,
|
||||
Models: raw.RuntimeConfig.Models,
|
||||
Skills: raw.Skills,
|
||||
SkillCount: len(raw.Skills),
|
||||
})
|
||||
|
||||
@ -129,6 +129,115 @@ skills:
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_RuntimeAndModelsRegistry(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "hermes")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
configYaml := `name: Hermes Agent
|
||||
description: test
|
||||
tier: 2
|
||||
runtime: hermes
|
||||
runtime_config:
|
||||
model: nous-hermes-3-70b
|
||||
models:
|
||||
- id: nous-hermes-3-70b
|
||||
name: Nous Hermes 3 70B
|
||||
required_env: [HERMES_API_KEY]
|
||||
- id: minimax/minimax-m2.7
|
||||
name: MiniMax M2.7 (via OpenRouter)
|
||||
required_env: [OPENROUTER_API_KEY]
|
||||
skills: []
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/templates", nil)
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp []templateSummary
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 template, got %d", len(resp))
|
||||
}
|
||||
got := resp[0]
|
||||
if got.Runtime != "hermes" {
|
||||
t.Errorf("Runtime: want hermes, got %q", got.Runtime)
|
||||
}
|
||||
if got.Model != "nous-hermes-3-70b" {
|
||||
t.Errorf("Model: want nous-hermes-3-70b (from runtime_config.model), got %q", got.Model)
|
||||
}
|
||||
if len(got.Models) != 2 {
|
||||
t.Fatalf("Models: want 2, got %d", len(got.Models))
|
||||
}
|
||||
if got.Models[0].ID != "nous-hermes-3-70b" || got.Models[0].Name != "Nous Hermes 3 70B" {
|
||||
t.Errorf("Models[0] id/name mismatch: %+v", got.Models[0])
|
||||
}
|
||||
if len(got.Models[0].RequiredEnv) != 1 || got.Models[0].RequiredEnv[0] != "HERMES_API_KEY" {
|
||||
t.Errorf("Models[0] required_env: want [HERMES_API_KEY], got %+v", got.Models[0].RequiredEnv)
|
||||
}
|
||||
if got.Models[1].ID != "minimax/minimax-m2.7" {
|
||||
t.Errorf("Models[1].ID: got %q", got.Models[1].ID)
|
||||
}
|
||||
if len(got.Models[1].RequiredEnv) != 1 || got.Models[1].RequiredEnv[0] != "OPENROUTER_API_KEY" {
|
||||
t.Errorf("Models[1] required_env: want [OPENROUTER_API_KEY], got %+v", got.Models[1].RequiredEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_LegacyTopLevelModel(t *testing.T) {
|
||||
// Older templates (pre-runtime_config) declared `model:` at the top level.
|
||||
// The /templates endpoint should keep surfacing those for backward compat.
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "legacy")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
configYaml := `name: Legacy Agent
|
||||
tier: 1
|
||||
model: anthropic:claude-sonnet-4-6
|
||||
skills: []
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/templates", nil)
|
||||
handler.List(c)
|
||||
|
||||
var resp []templateSummary
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 || resp[0].Model != "anthropic:claude-sonnet-4-6" {
|
||||
t.Errorf("legacy top-level model not surfaced: %+v", resp)
|
||||
}
|
||||
if resp[0].Runtime != "" {
|
||||
t.Errorf("Runtime should be empty for legacy template, got %q", resp[0].Runtime)
|
||||
}
|
||||
if len(resp[0].Models) != 0 {
|
||||
t.Errorf("Models should be empty for legacy template, got %+v", resp[0].Models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_NonexistentDir(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user