mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 02:24:41 +00:00
feat: Implement dynamic model selection for agents using a new backend API and a reusable combobox component.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ModelInfo is a normalized model entry returned by the list-models endpoint.
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// handleListProviderModels proxies to the upstream provider API to list
|
||||
// available models for the given provider.
|
||||
//
|
||||
// GET /v1/providers/{id}/models
|
||||
func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.store.GetProvider(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if p.APIKey == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider has no API key configured"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var models []ModelInfo
|
||||
|
||||
switch p.ProviderType {
|
||||
case "anthropic_native":
|
||||
models, err = fetchAnthropicModels(ctx, p.APIKey)
|
||||
case "openai_compat":
|
||||
apiBase := strings.TrimRight(p.APIBase, "/")
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey)
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("unsupported provider type: %s", p.ProviderType)})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("providers.models", "provider", p.Name, "error", err)
|
||||
// Return empty list instead of error — provider may not support /models
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"models": []ModelInfo{}})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"models": models})
|
||||
}
|
||||
|
||||
// fetchAnthropicModels calls the Anthropic models API.
|
||||
func fetchAnthropicModels(ctx context.Context, apiKey string) ([]ModelInfo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("anthropic API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode anthropic response: %w", err)
|
||||
}
|
||||
|
||||
models := make([]ModelInfo, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
models = append(models, ModelInfo{ID: m.ID, Name: m.DisplayName})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// fetchOpenAIModels calls an OpenAI-compatible /models endpoint.
|
||||
func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("provider API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode provider response: %w", err)
|
||||
}
|
||||
|
||||
models := make([]ModelInfo, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
models = append(models, ModelInfo{ID: m.ID, Name: m.OwnedBy})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
@@ -30,6 +30,8 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /v1/providers/{id}", h.auth(h.handleUpdateProvider))
|
||||
mux.HandleFunc("DELETE /v1/providers/{id}", h.auth(h.handleDeleteProvider))
|
||||
|
||||
// Model listing (proxied to upstream provider API)
|
||||
mux.HandleFunc("GET /v1/providers/{id}/models", h.auth(h.handleListProviderModels))
|
||||
}
|
||||
|
||||
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
+1
-1
@@ -36,5 +36,5 @@
|
||||
"vite": "^6.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264"
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDownIcon, CheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ComboboxOption[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync search text when value changes externally
|
||||
React.useEffect(() => {
|
||||
setSearch(value);
|
||||
}, [value]);
|
||||
|
||||
// Close on outside click
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!search) return options;
|
||||
const q = search.toLowerCase();
|
||||
return options.filter(
|
||||
(o) =>
|
||||
o.value.toLowerCase().includes(q) ||
|
||||
(o.label && o.label.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
const handleSelect = (val: string) => {
|
||||
onChange(val);
|
||||
setSearch(val);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setSearch(val);
|
||||
onChange(val);
|
||||
if (!open && options.length > 0) setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<input
|
||||
value={search}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => options.length > 0 && setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground dark:bg-input/30 h-9 w-full rounded-md border bg-transparent px-3 py-1 pr-8 text-sm shadow-xs outline-none transition-[color,box-shadow]",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
)}
|
||||
/>
|
||||
{options.length > 0 && (
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground absolute top-1/2 right-2.5 size-4 -translate-y-1/2 cursor-pointer opacity-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
)}
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border p-1 shadow-md">
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(o.value)}
|
||||
className="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
|
||||
>
|
||||
<span className="truncate">{o.label || o.value}</span>
|
||||
{o.value === value && (
|
||||
<CheckIcon className="absolute right-2 size-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
import { slugify, isValidSlug } from "@/lib/slug";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
|
||||
|
||||
interface AgentCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -35,6 +37,15 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
const [agentType, setAgentType] = useState<"open" | "predefined">("open");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
|
||||
// Look up provider ID from selected provider name for model fetching
|
||||
const selectedProviderId = useMemo(
|
||||
() => enabledProviders.find((p) => p.name === provider)?.id,
|
||||
[enabledProviders, provider],
|
||||
);
|
||||
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!agentKey.trim()) return;
|
||||
setLoading(true);
|
||||
@@ -60,7 +71,10 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
}
|
||||
};
|
||||
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
const handleProviderChange = (value: string) => {
|
||||
setProvider(value);
|
||||
setModel("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -92,7 +106,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
{enabledProviders.length > 0 ? (
|
||||
<Select value={provider} onValueChange={setProvider}>
|
||||
<Select value={provider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
@@ -114,10 +128,11 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Input
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="anthropic/claude-sonnet-4-5-20250929"
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Save, Copy, Check, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface AgentGeneralTabProps {
|
||||
@@ -27,6 +29,12 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
|
||||
const [provider, setProvider] = useState(agent.provider);
|
||||
const [model, setModel] = useState(agent.model);
|
||||
|
||||
const selectedProviderId = useMemo(
|
||||
() => enabledProviders.find((p) => p.name === provider)?.id,
|
||||
[enabledProviders, provider],
|
||||
);
|
||||
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
|
||||
const [contextWindow, setContextWindow] = useState(agent.context_window);
|
||||
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations);
|
||||
const [workspace, setWorkspace] = useState(agent.workspace);
|
||||
@@ -135,7 +143,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
{enabledProviders.length > 0 ? (
|
||||
<Select value={provider} onValueChange={setProvider}>
|
||||
<Select value={provider} onValueChange={(v) => { setProvider(v); setModel(""); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
@@ -157,11 +165,11 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Input
|
||||
id="model"
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="anthropic/claude-sonnet-4-5-20250929"
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,14 +96,16 @@ export function AgentsPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function useProviderModels(providerId: string | undefined) {
|
||||
const http = useHttp();
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(
|
||||
async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await http.get<{ models: ModelInfo[] }>(
|
||||
`/v1/providers/${id}/models`,
|
||||
);
|
||||
setModels(res.models ?? []);
|
||||
} catch {
|
||||
setModels([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerId) {
|
||||
setModels([]);
|
||||
return;
|
||||
}
|
||||
load(providerId);
|
||||
}, [providerId, load]);
|
||||
|
||||
return { models, loading };
|
||||
}
|
||||
Reference in New Issue
Block a user