feat(KB-184): add model presets for task creation
- Add model preset types and settings fields with API validation - Create model preset utility helpers for size-based auto-selection - Add settings UI for managing model presets (create, edit, delete) - Integrate preset selection into NewTaskModal with custom override option - Add inline preset selection to InlineCreateCard component - Persist modelPresetId in task store and preserve through archive/unarchive - Add changeset and update AGENTS.md with feature documentation
This commit is contained in:
@@ -99,6 +99,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
@@ -113,6 +114,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Brain, Link, Lightbulb, ListTree } from "lucide-react";
|
||||
import type { Task, TaskCreateInput } from "@kb/core";
|
||||
import { Brain, Link, Lightbulb, ListTree, Zap } from "lucide-react";
|
||||
import type { Task, TaskCreateInput, Settings } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, uploadAttachment } from "../api";
|
||||
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
@@ -68,6 +69,9 @@ export function InlineCreateCard({
|
||||
const [showDeps, setShowDeps] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [showModels, setShowModels] = useState(false);
|
||||
const [showPresets, setShowPresets] = useState(false);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
|
||||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
|
||||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
|
||||
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
|
||||
@@ -136,6 +140,18 @@ export function InlineCreateCard({
|
||||
}
|
||||
});
|
||||
|
||||
fetchSettings()
|
||||
.then((nextSettings) => {
|
||||
if (!cancelled) {
|
||||
setSettings(nextSettings);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSettings(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -143,6 +159,8 @@ export function InlineCreateCard({
|
||||
|
||||
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
||||
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
|
||||
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
|
||||
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
|
||||
@@ -172,7 +190,8 @@ export function InlineCreateCard({
|
||||
!hasExecutorOverride &&
|
||||
!hasValidatorOverride &&
|
||||
!showDeps &&
|
||||
!showModels
|
||||
!showModels &&
|
||||
!showPresets
|
||||
) {
|
||||
onCancel();
|
||||
}
|
||||
@@ -187,6 +206,7 @@ export function InlineCreateCard({
|
||||
hasValidatorOverride,
|
||||
showDeps,
|
||||
showModels,
|
||||
showPresets,
|
||||
onCancel,
|
||||
]);
|
||||
|
||||
@@ -239,6 +259,7 @@ export function InlineCreateCard({
|
||||
description: description.trim(),
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
modelPresetId: selectedPresetId,
|
||||
modelProvider: hasExecutorOverride ? executorProvider : undefined,
|
||||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||
@@ -264,6 +285,7 @@ export function InlineCreateCard({
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
|
||||
setSelectedPresetId(undefined);
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
@@ -359,8 +381,10 @@ export function InlineCreateCard({
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setSelectedPresetId(undefined);
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setShowPresets(false);
|
||||
}, [description, onPlanningMode, addToast]);
|
||||
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
@@ -377,8 +401,10 @@ export function InlineCreateCard({
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setSelectedPresetId(undefined);
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setShowPresets(false);
|
||||
}, [description, onSubtaskBreakdown, addToast]);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
@@ -478,6 +504,70 @@ export function InlineCreateCard({
|
||||
</div>
|
||||
|
||||
<div className="inline-create-model-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-model-trigger"
|
||||
onClick={() => {
|
||||
setShowPresets((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-expanded={showPresets}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Zap size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedPreset ? ` ${selectedPreset.name}` : " Preset"}
|
||||
</button>
|
||||
{showPresets && (
|
||||
<div className="inline-create-model-dropdown" onMouseDown={handleModelDropdownMouseDown}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setSelectedPresetId(undefined);
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setShowPresets(false);
|
||||
}}
|
||||
>
|
||||
Use default
|
||||
</button>
|
||||
{availablePresets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
const selection = applyPresetToSelection(preset);
|
||||
const executor = parseModelSelection(selection.executorValue);
|
||||
const validator = parseModelSelection(selection.validatorValue);
|
||||
setSelectedPresetId(preset.id);
|
||||
setExecutorProvider(executor.provider);
|
||||
setExecutorModelId(executor.modelId);
|
||||
setValidatorProvider(validator.provider);
|
||||
setValidatorModelId(validator.modelId);
|
||||
setShowPresets(false);
|
||||
}}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowPresets(false)}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-model-trigger"
|
||||
@@ -486,9 +576,11 @@ export function InlineCreateCard({
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedModelCount > 0
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
{selectedPreset
|
||||
? ` ${selectedPreset.name} · ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: selectedModelCount > 0
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
</button>
|
||||
{showModels && (
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import type { Task, TaskCreateInput } from "@kb/core";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, updateTask } from "../api";
|
||||
import { uploadAttachment, fetchModels, fetchSettings } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
@@ -311,6 +312,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
const [validatorModel, setValidatorModel] = useState("");
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [enablePlanningMode, setEnablePlanningMode] = useState(false);
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
|
||||
@@ -326,6 +330,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
.then((models) => setAvailableModels(models))
|
||||
.catch(() => {/* silently fail - models just won't be available */})
|
||||
.finally(() => setModelsLoading(false));
|
||||
fetchSettings()
|
||||
.then((nextSettings) => setSettings(nextSettings))
|
||||
.catch(() => setSettings(null));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -341,6 +348,21 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode]);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !settings?.autoSelectModelPreset) return;
|
||||
const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets);
|
||||
if (recommended) {
|
||||
const selection = applyPresetToSelection(recommended);
|
||||
setSelectedPresetId(recommended.id);
|
||||
setPresetMode("preset");
|
||||
setExecutorModel(selection.executorValue);
|
||||
setValidatorModel(selection.validatorValue);
|
||||
}
|
||||
}, [isOpen, settings, availablePresets]);
|
||||
|
||||
// Auto-focus description textarea when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -430,6 +452,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
@@ -452,6 +476,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
|
||||
// Close modal and trigger planning mode
|
||||
@@ -466,11 +492,19 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Create the base task
|
||||
const executorSlashIdx = executorModel.indexOf("/");
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
|
||||
const task = await onCreateTask({
|
||||
title: undefined,
|
||||
description: trimmedDesc,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
|
||||
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
|
||||
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
|
||||
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
|
||||
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -488,28 +522,6 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
}
|
||||
}
|
||||
|
||||
// Update task with model settings if specified
|
||||
const executorSlashIdx = executorModel.indexOf("/");
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
|
||||
if (executorModel || validatorModel) {
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
|
||||
if (executorModel && executorSlashIdx !== -1) {
|
||||
updates.modelProvider = executorModel.slice(0, executorSlashIdx);
|
||||
updates.modelId = executorModel.slice(executorSlashIdx + 1);
|
||||
}
|
||||
|
||||
if (validatorModel && validatorSlashIdx !== -1) {
|
||||
updates.validatorModelProvider = validatorModel.slice(0, validatorSlashIdx);
|
||||
updates.validatorModelId = validatorModel.slice(validatorSlashIdx + 1);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateTask(task.id, updates);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
@@ -517,6 +529,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
@@ -663,15 +677,68 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
<small>No models available. Configure authentication in Settings.</small>
|
||||
) : (
|
||||
<>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="model-preset" className="model-select-label">Preset</label>
|
||||
<select
|
||||
id="model-preset"
|
||||
value={presetMode === "preset" ? selectedPresetId : presetMode}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "default") {
|
||||
setPresetMode("default");
|
||||
setSelectedPresetId("");
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
return;
|
||||
}
|
||||
if (value === "custom") {
|
||||
setPresetMode("custom");
|
||||
setSelectedPresetId("");
|
||||
return;
|
||||
}
|
||||
const preset = availablePresets.find((entry) => entry.id === value);
|
||||
const selection = applyPresetToSelection(preset);
|
||||
setPresetMode("preset");
|
||||
setSelectedPresetId(value);
|
||||
setExecutorModel(selection.executorValue);
|
||||
setValidatorModel(selection.validatorValue);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="default">Use default</option>
|
||||
{availablePresets.length > 0 ? <option disabled>──────────</option> : null}
|
||||
{availablePresets.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
{presetMode === "preset" && selectedPreset ? (
|
||||
<small>Using preset: {selectedPreset.name}</small>
|
||||
) : null}
|
||||
{presetMode === "preset" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPresetMode("custom")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Override
|
||||
</button>
|
||||
) : null}
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="executor-model" className="model-select-label">Executor</label>
|
||||
<ModelCombobox
|
||||
id="executor-model"
|
||||
label="Executor Model"
|
||||
value={executorModel}
|
||||
onChange={setExecutorModel}
|
||||
onChange={(value) => {
|
||||
setPresetMode("custom");
|
||||
setSelectedPresetId("");
|
||||
setExecutorModel(value);
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || presetMode === "preset"}
|
||||
/>
|
||||
</div>
|
||||
<div className="model-select-row">
|
||||
@@ -680,9 +747,13 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
id="validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorModel}
|
||||
onChange={setValidatorModel}
|
||||
onChange={(value) => {
|
||||
setPresetMode("custom");
|
||||
setSelectedPresetId("");
|
||||
setValidatorModel(value);
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || presetMode === "preset"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { THINKING_LEVELS } from "@kb/core";
|
||||
import type { Settings, ThemeMode, ColorTheme } from "@kb/core";
|
||||
import type { Settings, ThemeMode, ColorTheme, ModelPreset } from "@kb/core";
|
||||
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
|
||||
import type { AuthProvider, ModelInfo } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { applyPresetToSelection, generatePresetId, validatePresetId } from "../utils/modelPresets";
|
||||
|
||||
/**
|
||||
* Settings sections configuration.
|
||||
@@ -29,6 +30,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
const SETTINGS_SECTIONS = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "model", label: "Model" },
|
||||
{ id: "model-presets", label: "Model Presets" },
|
||||
{ id: "appearance", label: "Appearance" },
|
||||
{ id: "scheduling", label: "Scheduling" },
|
||||
{ id: "worktrees", label: "Worktrees" },
|
||||
@@ -81,6 +83,9 @@ export function SettingsModal({
|
||||
|
||||
// Test notification state
|
||||
const [testNotificationLoading, setTestNotificationLoading] = useState(false);
|
||||
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
|
||||
const [presetDraft, setPresetDraft] = useState<ModelPreset | null>(null);
|
||||
const [presetIdTouched, setPresetIdTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
@@ -208,7 +213,7 @@ export function SettingsModal({
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (prefixError) return;
|
||||
if (prefixError || presetDraft) return;
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
@@ -223,6 +228,44 @@ export function SettingsModal({
|
||||
}
|
||||
}, [form, prefixError, onClose, addToast]);
|
||||
|
||||
const savePresetDraft = () => {
|
||||
if (!presetDraft) return;
|
||||
|
||||
const nextId = presetDraft.id.trim();
|
||||
const nextName = presetDraft.name.trim();
|
||||
if (!nextName || !nextId || !validatePresetId(nextId)) {
|
||||
addToast("Preset name is required and ID must be 1–32 letters, numbers, hyphens, or underscores", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const presets = form.modelPresets || [];
|
||||
if (presets.some((preset) => preset.id === nextId && preset.id !== editingPresetId)) {
|
||||
addToast("Preset ID must be unique", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDraft: ModelPreset = {
|
||||
id: nextId,
|
||||
name: nextName,
|
||||
executorProvider: presetDraft.executorProvider,
|
||||
executorModelId: presetDraft.executorModelId,
|
||||
validatorProvider: presetDraft.validatorProvider,
|
||||
validatorModelId: presetDraft.validatorModelId,
|
||||
};
|
||||
|
||||
setForm((current) => {
|
||||
const existing = current.modelPresets || [];
|
||||
const nextPresets = editingPresetId
|
||||
? existing.map((preset) => (preset.id === editingPresetId ? normalizedDraft : preset))
|
||||
: [...existing, normalizedDraft];
|
||||
return { ...current, modelPresets: nextPresets };
|
||||
});
|
||||
|
||||
setEditingPresetId(null);
|
||||
setPresetDraft(null);
|
||||
setPresetIdTouched(false);
|
||||
};
|
||||
|
||||
const renderSectionFields = () => {
|
||||
switch (activeSection) {
|
||||
case "general":
|
||||
@@ -387,6 +430,223 @@ export function SettingsModal({
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "model-presets": {
|
||||
const presets = form.modelPresets || [];
|
||||
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
|
||||
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean));
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Model Presets</h4>
|
||||
<div className="form-group">
|
||||
<label>Configured presets</label>
|
||||
{presets.length === 0 ? (
|
||||
<div className="settings-empty-state settings-muted">No presets configured yet.</div>
|
||||
) : (
|
||||
<div className="settings-preset-list">
|
||||
{presets.map((preset) => {
|
||||
const selection = applyPresetToSelection(preset);
|
||||
const summary = `${selection.executorValue || "default"} / ${selection.validatorValue || "default"}`;
|
||||
return (
|
||||
<div key={preset.id} className="auth-provider-row">
|
||||
<div className="auth-provider-info">
|
||||
<strong>{preset.name}</strong>
|
||||
<span className="settings-muted">{summary}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingPresetId(preset.id);
|
||||
setPresetDraft({ ...preset });
|
||||
setPresetIdTouched(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
if (inUsePresetIds.has(preset.id) && !confirm(`Preset \"${preset.name}\" is used in auto-selection. Delete it anyway?`)) {
|
||||
return;
|
||||
}
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
modelPresets: (current.modelPresets || []).filter((entry) => entry.id !== preset.id),
|
||||
defaultPresetBySize: Object.fromEntries(
|
||||
Object.entries(current.defaultPresetBySize || {}).filter(([, value]) => value !== preset.id),
|
||||
) as Settings["defaultPresetBySize"],
|
||||
}));
|
||||
if (editingPresetId === preset.id) {
|
||||
setEditingPresetId(null);
|
||||
setPresetDraft(null);
|
||||
setPresetIdTouched(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!presetDraft ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setEditingPresetId(null);
|
||||
setPresetDraft({ id: "", name: "", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined });
|
||||
setPresetIdTouched(false);
|
||||
}}
|
||||
>
|
||||
Add Preset
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{presetDraft ? (
|
||||
<div className="form-group">
|
||||
<label>Preset editor</label>
|
||||
<div className="form-group">
|
||||
<label htmlFor="preset-name">Name</label>
|
||||
<input
|
||||
id="preset-name"
|
||||
type="text"
|
||||
value={presetDraft.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
setPresetDraft((current) => current ? {
|
||||
...current,
|
||||
name,
|
||||
id: presetIdTouched ? current.id : generatePresetId(name),
|
||||
} : current);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="preset-id">ID</label>
|
||||
<input
|
||||
id="preset-id"
|
||||
type="text"
|
||||
value={presetDraft.id}
|
||||
onChange={(e) => {
|
||||
setPresetIdTouched(true);
|
||||
setPresetDraft((current) => current ? { ...current, id: e.target.value } : current);
|
||||
}}
|
||||
/>
|
||||
{presetDraft.id && !validatePresetId(presetDraft.id) ? (
|
||||
<small className="field-error">ID must be 1–32 letters, numbers, hyphens, or underscores</small>
|
||||
) : (
|
||||
<small>Slug-friendly unique identifier used for preset mappings.</small>
|
||||
)}
|
||||
</div>
|
||||
{availableModels.length === 0 ? (
|
||||
<small>No models available. Configure authentication first.</small>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="preset-executor-model">Executor model</label>
|
||||
<CustomModelDropdown
|
||||
id="preset-executor-model"
|
||||
label="Preset executor model"
|
||||
models={availableModels}
|
||||
value={presetDraft.executorProvider && presetDraft.executorModelId ? `${presetDraft.executorProvider}/${presetDraft.executorModelId}` : ""}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined } : current);
|
||||
return;
|
||||
}
|
||||
const slashIdx = val.indexOf("/");
|
||||
setPresetDraft((current) => current ? {
|
||||
...current,
|
||||
executorProvider: val.slice(0, slashIdx),
|
||||
executorModelId: val.slice(slashIdx + 1),
|
||||
} : current);
|
||||
}}
|
||||
placeholder="Use default"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="preset-validator-model">Validator model</label>
|
||||
<CustomModelDropdown
|
||||
id="preset-validator-model"
|
||||
label="Preset validator model"
|
||||
models={availableModels}
|
||||
value={presetDraft.validatorProvider && presetDraft.validatorModelId ? `${presetDraft.validatorProvider}/${presetDraft.validatorModelId}` : ""}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined } : current);
|
||||
return;
|
||||
}
|
||||
const slashIdx = val.indexOf("/");
|
||||
setPresetDraft((current) => current ? {
|
||||
...current,
|
||||
validatorProvider: val.slice(0, slashIdx),
|
||||
validatorModelId: val.slice(slashIdx + 1),
|
||||
} : current);
|
||||
}}
|
||||
placeholder="Use default"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="modal-actions" style={{ justifyContent: "flex-start" }}>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={savePresetDraft}>Save preset</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => { setEditingPresetId(null); setPresetDraft(null); setPresetIdTouched(false); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="autoSelectModelPreset" className="checkbox-label">
|
||||
<input
|
||||
id="autoSelectModelPreset"
|
||||
type="checkbox"
|
||||
checked={form.autoSelectModelPreset || false}
|
||||
onChange={(e) => setForm((current) => ({ ...current, autoSelectModelPreset: e.target.checked }))}
|
||||
/>
|
||||
Auto-select preset based on task size
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{form.autoSelectModelPreset ? (
|
||||
<>
|
||||
{(["S", "M", "L"] as const).map((sizeKey) => (
|
||||
<div className="form-group" key={sizeKey}>
|
||||
<label htmlFor={`preset-size-${sizeKey}`}>
|
||||
{sizeKey === "S" ? "Small tasks (S):" : sizeKey === "M" ? "Medium tasks (M):" : "Large tasks (L):"}
|
||||
</label>
|
||||
<select
|
||||
id={`preset-size-${sizeKey}`}
|
||||
value={form.defaultPresetBySize?.[sizeKey] || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value || undefined;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
defaultPresetBySize: {
|
||||
...(current.defaultPresetBySize || {}),
|
||||
[sizeKey]: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">No preset</option>
|
||||
{presetOptions.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "appearance":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { InlineCreateCard } from "../InlineCreateCard";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
import { fetchModels } from "../../api";
|
||||
import { fetchModels, fetchSettings } from "../../api";
|
||||
import type { ModelInfo } from "../../api";
|
||||
|
||||
// Mock lucide-react
|
||||
@@ -16,11 +16,17 @@ vi.mock("lucide-react", () => ({
|
||||
Terminal: () => null,
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
Zap: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue([]),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
uploadAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -85,6 +91,11 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard blur-to-cancel", () => {
|
||||
@@ -266,6 +277,48 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the preset dropdown is open", () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes selected preset id in the submit payload", async () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "Task with preset",
|
||||
modelPresetId: "budget",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { NewTaskModal } from "../NewTaskModal";
|
||||
import type { Task, Column } from "@kb/core";
|
||||
import { fetchSettings } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -10,7 +11,11 @@ vi.mock("../../api", () => ({
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
]),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -196,6 +201,67 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates task with selected preset id and resolved models", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
modelPresets: [
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
executorProvider: "openai",
|
||||
executorModelId: "gpt-4o",
|
||||
validatorProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
},
|
||||
],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Preset task" } });
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("Preset")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "budget" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "Preset task",
|
||||
modelPresetId: "budget",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("allows overriding a selected preset with custom models", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
modelPresets: [
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
executorProvider: "openai",
|
||||
executorModelId: "gpt-4o",
|
||||
},
|
||||
],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
|
||||
renderNewTaskModal();
|
||||
await waitFor(() => expect(screen.getByLabelText("Preset")).toBeTruthy());
|
||||
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "budget" } });
|
||||
expect(screen.getByText("Using preset: Budget")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Override" }));
|
||||
expect(screen.queryByText("Using preset: Budget")).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "custom" } });
|
||||
expect((screen.getByLabelText("Preset") as HTMLSelectElement).value).toBe("custom");
|
||||
});
|
||||
|
||||
it("toggles planning mode checkbox", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ const defaultSettings: Settings = {
|
||||
buildCommand: "",
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
taskStuckTimeoutMs: undefined,
|
||||
@@ -373,6 +376,55 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getAllByText("Model").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("supports creating and saving a model preset", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model Presets"));
|
||||
await user.click(screen.getByText("Add Preset"));
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "Budget");
|
||||
expect((screen.getByLabelText("ID") as HTMLInputElement).value).toBe("budget");
|
||||
|
||||
await user.click(screen.getByText("Save preset"));
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.modelPresets).toEqual([
|
||||
expect.objectContaining({ id: "budget", name: "Budget" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports auto-select preset mappings by size", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
modelPresets: [
|
||||
{ id: "budget", name: "Budget" },
|
||||
{ id: "normal", name: "Normal" },
|
||||
{ id: "complex", name: "Complex" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Model Presets"));
|
||||
await user.click(screen.getByLabelText("Auto-select preset based on task size"));
|
||||
fireEvent.change(screen.getByLabelText("Small tasks (S):"), { target: { value: "budget" } });
|
||||
fireEvent.change(screen.getByLabelText("Medium tasks (M):"), { target: { value: "normal" } });
|
||||
fireEvent.change(screen.getByLabelText("Large tasks (L):"), { target: { value: "complex" } });
|
||||
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.autoSelectModelPreset).toBe(true);
|
||||
expect(payload.defaultPresetBySize).toEqual({ S: "budget", M: "normal", L: "complex" });
|
||||
});
|
||||
|
||||
it("shows model selector with available models grouped by provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
73
packages/dashboard/app/utils/modelPresets.test.ts
Normal file
73
packages/dashboard/app/utils/modelPresets.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelPreset } from "@kb/core";
|
||||
import {
|
||||
applyPresetToSelection,
|
||||
generatePresetId,
|
||||
getPresetByName,
|
||||
getRecommendedPresetForSize,
|
||||
validatePresetId,
|
||||
} from "./modelPresets";
|
||||
|
||||
const presets: ModelPreset[] = [
|
||||
{
|
||||
id: "budget",
|
||||
name: "Budget",
|
||||
executorProvider: "openai",
|
||||
executorModelId: "gpt-4o-mini",
|
||||
validatorProvider: "openai",
|
||||
validatorModelId: "gpt-4o-mini",
|
||||
},
|
||||
{
|
||||
id: "complex",
|
||||
name: "Complex",
|
||||
executorProvider: "anthropic",
|
||||
executorModelId: "claude-sonnet-4-5",
|
||||
},
|
||||
];
|
||||
|
||||
describe("modelPresets utils", () => {
|
||||
it("finds presets by case-insensitive display name", () => {
|
||||
expect(getPresetByName(presets, "budget")).toEqual(presets[0]);
|
||||
expect(getPresetByName(presets, " COMPLEX ")).toEqual(presets[1]);
|
||||
expect(getPresetByName(presets, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies a preset to dropdown selection values", () => {
|
||||
expect(applyPresetToSelection(presets[0])).toEqual({
|
||||
executorValue: "openai/gpt-4o-mini",
|
||||
validatorValue: "openai/gpt-4o-mini",
|
||||
});
|
||||
expect(applyPresetToSelection(undefined)).toEqual({
|
||||
executorValue: "",
|
||||
validatorValue: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("recommends the mapped preset for a task size", () => {
|
||||
expect(
|
||||
getRecommendedPresetForSize("S", { S: "budget", M: "complex" }, presets),
|
||||
).toEqual(presets[0]);
|
||||
expect(
|
||||
getRecommendedPresetForSize("L", { S: "budget", M: "complex" }, presets),
|
||||
).toBeUndefined();
|
||||
expect(getRecommendedPresetForSize(undefined, { S: "budget" }, presets)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates preset ids", () => {
|
||||
expect(validatePresetId("budget")).toBe(true);
|
||||
expect(validatePresetId("budget_v2")).toBe(true);
|
||||
expect(validatePresetId("budget-v2")).toBe(true);
|
||||
expect(validatePresetId("")).toBe(false);
|
||||
expect(validatePresetId("has spaces")).toBe(false);
|
||||
expect(validatePresetId("invalid!char")).toBe(false);
|
||||
expect(validatePresetId("a".repeat(33))).toBe(false);
|
||||
});
|
||||
|
||||
it("generates slug-friendly preset ids", () => {
|
||||
expect(generatePresetId("Budget")).toBe("budget");
|
||||
expect(generatePresetId(" Normal Mode ")).toBe("normal-mode");
|
||||
expect(generatePresetId("Complex / Reviewer")).toBe("complex-reviewer");
|
||||
expect(generatePresetId("!!!")).toBe("preset");
|
||||
expect(generatePresetId("a".repeat(40))).toBe("a".repeat(32));
|
||||
});
|
||||
});
|
||||
47
packages/dashboard/app/utils/modelPresets.ts
Normal file
47
packages/dashboard/app/utils/modelPresets.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ModelPreset } from "@kb/core";
|
||||
|
||||
export function getPresetByName(presets: ModelPreset[], name: string): ModelPreset | undefined {
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
return presets.find((preset) => preset.name.trim().toLowerCase() === normalizedName);
|
||||
}
|
||||
|
||||
export function applyPresetToSelection(preset: ModelPreset | undefined): {
|
||||
executorValue: string;
|
||||
validatorValue: string;
|
||||
} {
|
||||
return {
|
||||
executorValue: preset?.executorProvider && preset?.executorModelId
|
||||
? `${preset.executorProvider}/${preset.executorModelId}`
|
||||
: "",
|
||||
validatorValue: preset?.validatorProvider && preset?.validatorModelId
|
||||
? `${preset.validatorProvider}/${preset.validatorModelId}`
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function getRecommendedPresetForSize(
|
||||
size: "S" | "M" | "L" | undefined,
|
||||
defaultPresetBySize: Record<string, string>,
|
||||
presets: ModelPreset[],
|
||||
): ModelPreset | undefined {
|
||||
if (!size) return undefined;
|
||||
const presetId = defaultPresetBySize[size];
|
||||
if (!presetId) return undefined;
|
||||
return presets.find((preset) => preset.id === presetId);
|
||||
}
|
||||
|
||||
export function validatePresetId(id: string): boolean {
|
||||
return /^[A-Za-z0-9_-]{1,32}$/.test(id);
|
||||
}
|
||||
|
||||
export function generatePresetId(name: string): string {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "")
|
||||
.slice(0, 32);
|
||||
|
||||
return slug || "preset";
|
||||
}
|
||||
@@ -4440,6 +4440,53 @@ describe("PUT /settings", () => {
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ maxWorktrees: 10 });
|
||||
});
|
||||
|
||||
it("validates and forwards model presets", async () => {
|
||||
const updatedSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }],
|
||||
};
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini", validatorProvider: undefined, validatorModelId: undefined }],
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects duplicate preset ids", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget" }, { id: "budget", name: "Budget 2" }] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("duplicate id");
|
||||
});
|
||||
|
||||
it("rejects incomplete model provider/modelId pairs", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai" }] }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("must include both provider and modelId or neither");
|
||||
});
|
||||
|
||||
it("returns 500 on store update error", async () => {
|
||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
|
||||
import multer from "multer";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType } from "@kb/core";
|
||||
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||
@@ -74,6 +74,74 @@ function normalizeModelSelectionPair(provider: string | undefined, modelId: stri
|
||||
return { provider, modelId };
|
||||
}
|
||||
|
||||
function assertConsistentOptionalPair(
|
||||
provider: unknown,
|
||||
modelId: unknown,
|
||||
pairName: string,
|
||||
): { provider?: string; modelId?: string } {
|
||||
const normalizedProvider = validateOptionalModelField(provider, `${pairName} provider`);
|
||||
const normalizedModelId = validateOptionalModelField(modelId, `${pairName} modelId`);
|
||||
|
||||
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
|
||||
throw new Error(`${pairName} must include both provider and modelId or neither`);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: normalizedProvider,
|
||||
modelId: normalizedModelId,
|
||||
};
|
||||
}
|
||||
|
||||
function validateModelPresets(value: unknown): ModelPreset[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("modelPresets must be an array");
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
return value.map((preset, index) => {
|
||||
if (!preset || typeof preset !== "object") {
|
||||
throw new Error(`modelPresets[${index}] must be an object`);
|
||||
}
|
||||
|
||||
const candidate = preset as Record<string, unknown>;
|
||||
const id = validateOptionalModelField(candidate.id, `modelPresets[${index}].id`);
|
||||
const name = validateOptionalModelField(candidate.name, `modelPresets[${index}].name`);
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`modelPresets[${index}].id is required`);
|
||||
}
|
||||
if (!name) {
|
||||
throw new Error(`modelPresets[${index}].name is required`);
|
||||
}
|
||||
if (seenIds.has(id)) {
|
||||
throw new Error(`modelPresets contains duplicate id: ${id}`);
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
const executor = assertConsistentOptionalPair(
|
||||
candidate.executorProvider,
|
||||
candidate.executorModelId,
|
||||
`modelPresets[${index}].executor`,
|
||||
);
|
||||
const validator = assertConsistentOptionalPair(
|
||||
candidate.validatorProvider,
|
||||
candidate.validatorModelId,
|
||||
`modelPresets[${index}].validator`,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
executorProvider: executor.provider,
|
||||
executorModelId: executor.modelId,
|
||||
validatorProvider: validator.provider,
|
||||
validatorModelId: validator.modelId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Git Remote Detection ──────────────────────────────────────────
|
||||
|
||||
/** Git remote info returned by the remotes endpoint */
|
||||
@@ -863,10 +931,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// These are computed server-side and injected only on GET /settings.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { githubTokenConfigured, ...clientSettings } = req.body;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
|
||||
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
|
||||
}
|
||||
|
||||
const settings = await store.updateSettings(clientSettings);
|
||||
res.json(settings);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
const status = typeof err?.message === "string" && (
|
||||
err.message.includes("modelPresets") || err.message.includes("must include both provider and modelId")
|
||||
) ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -952,6 +1028,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
@@ -980,6 +1057,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
column,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
||||
modelProvider: executorModel.provider,
|
||||
modelId: executorModel.modelId,
|
||||
validatorModelProvider: validatorModel.provider,
|
||||
|
||||
Reference in New Issue
Block a user