diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 20338cd8..7d177ebf 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -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({ ...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(FALLBACK_RUNTIME_OPTIONS); const successTimerRef = useRef>(undefined); useEffect(() => { @@ -120,6 +151,36 @@ export function ConfigTab({ workspaceId }: Props) { loadConfig(); }, [loadConfig]); + useEffect(() => { + let cancelled = false; + api.get>("/templates") + .then((rows) => { + if (cancelled || !Array.isArray(rows)) return; + const byRuntime = new Map(); + 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 = (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" > - - - - - - + {runtimeOptions.map((opt) => ( + + ))} - { - if (config.runtime) { - update("runtime_config", { ...config.runtime_config, model: v }); - } else { - update("model", v); - } - }} placeholder="e.g. anthropic:claude-sonnet-4-6" mono /> +
+ + 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 && ( + + {availableModels.map((m, i) => ( + + ))} + + )} +
- updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" /> + 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) && ( +
+ + Template suggests{" "} + {currentModelSpec.required_env.join(", ")}{" "} + for {currentModelSpec.name || currentModelSpec.id}. + + +
+ )} {/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */} diff --git a/workspace-server/internal/handlers/templates.go b/workspace-server/internal/handlers/templates.go index 27595aff..0fd55847 100644 --- a/workspace-server/internal/handlers/templates.go +++ b/workspace-server/internal/handlers/templates.go @@ -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), }) diff --git a/workspace-server/internal/handlers/templates_test.go b/workspace-server/internal/handlers/templates_test.go index 3f7097bc..8d47b9b9 100644 --- a/workspace-server/internal/handlers/templates_test.go +++ b/workspace-server/internal/handlers/templates_test.go @@ -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)