Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 151 additions & 14 deletions canvas/src/components/tabs/ConfigTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand All @@ -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(() => {
Expand All @@ -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 }));
};
Expand Down Expand Up @@ -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 */}
Expand Down
50 changes: 37 additions & 13 deletions workspace-server/internal/handlers/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
})
Expand Down
109 changes: 109 additions & 0 deletions workspace-server/internal/handlers/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading