All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ChangeEvent,
|
|
type ReactElement,
|
|
type SyntheticEvent,
|
|
} from "react";
|
|
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
fetchFleetAgentConfig,
|
|
fetchFleetProviders,
|
|
updateFleetAgentConfig,
|
|
type FleetProvider,
|
|
type FleetProviderModel,
|
|
type UpdateFleetAgentConfigRequest,
|
|
} from "@/lib/api/fleet-settings";
|
|
|
|
function getErrorMessage(error: unknown, fallback: string): string {
|
|
if (error instanceof Error && error.message.trim().length > 0) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
|
|
if (!Array.isArray(models)) {
|
|
return [];
|
|
}
|
|
|
|
const parsed: FleetProviderModel[] = [];
|
|
|
|
models.forEach((entry) => {
|
|
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
parsed.push({ id: entry.trim(), name: entry.trim() });
|
|
return;
|
|
}
|
|
|
|
if (entry && typeof entry === "object") {
|
|
const record = entry as Record<string, unknown>;
|
|
const id =
|
|
typeof record.id === "string"
|
|
? record.id.trim()
|
|
: typeof record.name === "string"
|
|
? record.name.trim()
|
|
: "";
|
|
|
|
if (id.length > 0) {
|
|
parsed.push({ id, name: id });
|
|
}
|
|
}
|
|
});
|
|
|
|
const seen = new Set<string>();
|
|
return parsed.filter((model) => {
|
|
if (seen.has(model.id)) {
|
|
return false;
|
|
}
|
|
|
|
seen.add(model.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function parseModelList(value: string): string[] {
|
|
const seen = new Set<string>();
|
|
|
|
return value
|
|
.split(/\n|,/g)
|
|
.map((segment) => segment.trim())
|
|
.filter((segment) => segment.length > 0)
|
|
.filter((segment) => {
|
|
if (seen.has(segment)) {
|
|
return false;
|
|
}
|
|
|
|
seen.add(segment);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function deriveAvailableModels(providers: FleetProvider[]): string[] {
|
|
const seen = new Set<string>();
|
|
const models: string[] = [];
|
|
|
|
providers.forEach((provider) => {
|
|
normalizeProviderModels(provider.models).forEach((model) => {
|
|
if (seen.has(model.id)) {
|
|
return;
|
|
}
|
|
|
|
seen.add(model.id);
|
|
models.push(model.id);
|
|
});
|
|
});
|
|
|
|
return models.sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export default function AgentConfigSettingsPage(): ReactElement {
|
|
const [providers, setProviders] = useState<FleetProvider[]>([]);
|
|
const [primaryModel, setPrimaryModel] = useState<string>("");
|
|
const [fallbackModelsText, setFallbackModelsText] = useState<string>("");
|
|
const [personality, setPersonality] = useState<string>("");
|
|
|
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
|
|
const availableModels = useMemo(() => deriveAvailableModels(providers), [providers]);
|
|
const fallbackModels = useMemo(() => parseModelList(fallbackModelsText), [fallbackModelsText]);
|
|
|
|
const modelSelectOptions = useMemo(() => {
|
|
if (primaryModel.length > 0 && !availableModels.includes(primaryModel)) {
|
|
return [primaryModel, ...availableModels];
|
|
}
|
|
|
|
return availableModels;
|
|
}, [availableModels, primaryModel]);
|
|
|
|
const loadSettings = useCallback(async (): Promise<void> => {
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const [providerData, agentConfig] = await Promise.all([
|
|
fetchFleetProviders(),
|
|
fetchFleetAgentConfig(),
|
|
]);
|
|
setProviders(providerData);
|
|
setPrimaryModel(agentConfig.primaryModel ?? "");
|
|
setFallbackModelsText(agentConfig.fallbackModels.join("\n"));
|
|
setPersonality(agentConfig.personality ?? "");
|
|
setError(null);
|
|
} catch (loadError: unknown) {
|
|
setError(getErrorMessage(loadError, "Failed to load agent configuration."));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadSettings();
|
|
}, [loadSettings]);
|
|
|
|
function appendFallbackModel(model: string): void {
|
|
const current = parseModelList(fallbackModelsText);
|
|
if (current.includes(model)) {
|
|
return;
|
|
}
|
|
|
|
const next = [...current, model];
|
|
setFallbackModelsText(next.join("\n"));
|
|
}
|
|
|
|
async function handleSave(event: SyntheticEvent): Promise<void> {
|
|
event.preventDefault();
|
|
setError(null);
|
|
setSuccessMessage(null);
|
|
|
|
const updatePayload: UpdateFleetAgentConfigRequest = {
|
|
personality: personality.trim(),
|
|
};
|
|
|
|
if (primaryModel.trim().length > 0) {
|
|
updatePayload.primaryModel = primaryModel.trim();
|
|
}
|
|
|
|
const parsedFallbacks = parseModelList(fallbackModelsText).filter(
|
|
(model) => model !== primaryModel.trim()
|
|
);
|
|
if (parsedFallbacks.length > 0) {
|
|
updatePayload.fallbackModels = parsedFallbacks;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
await updateFleetAgentConfig(updatePayload);
|
|
setSuccessMessage("Agent configuration saved.");
|
|
await loadSettings();
|
|
} catch (saveError: unknown) {
|
|
setError(getErrorMessage(saveError, "Failed to save agent configuration."));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Agent Configuration</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Assign primary and fallback models for your agent runtime behavior.
|
|
</p>
|
|
</div>
|
|
<FleetSettingsNav />
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Current Assignment</CardTitle>
|
|
<CardDescription>
|
|
Snapshot of your currently saved model routing configuration.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{isLoading ? (
|
|
<p className="text-sm text-muted-foreground">Loading configuration...</p>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<p className="text-sm font-medium">Primary Model</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{primaryModel.length > 0 ? primaryModel : "No primary model configured"}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm font-medium">Fallback Models</p>
|
|
{fallbackModels.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No fallback models configured</p>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{fallbackModels.map((model) => (
|
|
<Badge key={`current-${model}`} variant="outline">
|
|
{model}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Update Agent Config</CardTitle>
|
|
<CardDescription>
|
|
Select a primary model and define fallback ordering. Models come from your provider
|
|
settings.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={(event) => void handleSave(event)} className="space-y-5">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="primary-model">Primary Model</Label>
|
|
<Select
|
|
value={primaryModel.length > 0 ? primaryModel : "__none__"}
|
|
onValueChange={(value) => {
|
|
setPrimaryModel(value === "__none__" ? "" : value);
|
|
}}
|
|
disabled={isLoading || isSaving}
|
|
>
|
|
<SelectTrigger id="primary-model">
|
|
<SelectValue placeholder="Select a primary model" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">No primary model selected</SelectItem>
|
|
{modelSelectOptions.map((model) => (
|
|
<SelectItem key={model} value={model}>
|
|
{model}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{availableModels.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
No models available yet. Add provider models first in Providers settings.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fallback-models">Fallback Models</Label>
|
|
<Textarea
|
|
id="fallback-models"
|
|
value={fallbackModelsText}
|
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
setFallbackModelsText(event.target.value);
|
|
}}
|
|
rows={4}
|
|
placeholder={"One model per line\nExample: gpt-4.1-mini"}
|
|
disabled={isLoading || isSaving}
|
|
/>
|
|
{availableModels.length > 0 ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{availableModels
|
|
.filter((model) => model !== primaryModel)
|
|
.map((model) => (
|
|
<Button
|
|
key={`suggest-${model}`}
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
appendFallbackModel(model);
|
|
}}
|
|
disabled={fallbackModels.includes(model) || isSaving}
|
|
>
|
|
{fallbackModels.includes(model) ? `Added: ${model}` : `Add ${model}`}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="agent-personality">Personality / SOUL</Label>
|
|
<Textarea
|
|
id="agent-personality"
|
|
value={personality}
|
|
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
setPersonality(event.target.value);
|
|
}}
|
|
rows={8}
|
|
placeholder="Optional system personality instructions..."
|
|
disabled={isLoading || isSaving}
|
|
/>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
{successMessage ? <p className="text-sm text-emerald-600">{successMessage}</p> : null}
|
|
|
|
<Button type="submit" disabled={isLoading || isSaving}>
|
|
{isSaving ? "Saving..." : "Save Agent Config"}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|