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:
Hongming Wang 2026-04-22 08:07:46 -07:00 committed by GitHub
parent 201e18f9ed
commit 359dc615e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 297 additions and 27 deletions

View File

@ -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 */}

View File

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

View File

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