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:
5
.changeset/add-model-presets.md
Normal file
5
.changeset/add-model-presets.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add model presets for configurable AI model profiles. Users can define preset configurations like "Budget", "Normal", and "Complex" with pre-assigned executor and validator models. Tasks can reference presets instead of manual model selection. Includes auto-selection feature that assigns presets based on task size (S/M/L).
|
||||||
31
AGENTS.md
31
AGENTS.md
@@ -245,6 +245,37 @@ Controls how worktree directory names are generated when `recycleWorktrees` is N
|
|||||||
- Task branches are always named `kb/{task-id}` regardless of this setting
|
- Task branches are always named `kb/{task-id}` regardless of this setting
|
||||||
- When using `"task-title"` mode, special characters are replaced with hyphens and the result is lowercased
|
- When using `"task-title"` mode, special characters are replaced with hyphens and the result is lowercased
|
||||||
|
|
||||||
|
## Model Presets
|
||||||
|
|
||||||
|
The kb dashboard supports reusable model presets so teams can standardize AI model choices without manually selecting executor and validator models for every task.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
Each preset contains:
|
||||||
|
- **ID** — stable slug used for storage and size mappings (for example `budget`, `normal`, `complex`)
|
||||||
|
- **Name** — human-friendly label shown in the UI
|
||||||
|
- **Executor model** — optional provider/model pair for task execution
|
||||||
|
- **Validator model** — optional provider/model pair for code and spec review
|
||||||
|
|
||||||
|
Task creation surfaces can apply a preset, which immediately resolves to concrete per-task model overrides. The selected preset ID is also stored on the task as `modelPresetId` for reference and future auditing.
|
||||||
|
|
||||||
|
### Auto-Selection by Task Size
|
||||||
|
|
||||||
|
Settings can optionally enable automatic preset recommendation by task size:
|
||||||
|
- **Small (`S`)** → mapped preset ID
|
||||||
|
- **Medium (`M`)** → mapped preset ID
|
||||||
|
- **Large (`L`)** → mapped preset ID
|
||||||
|
|
||||||
|
When enabled, task creation UIs can preselect the configured preset for the detected task size. If no mapping exists for a given size, kb falls back to normal default-model behavior.
|
||||||
|
|
||||||
|
### Interaction with Per-Task Overrides
|
||||||
|
|
||||||
|
Presets are an alternative to manual per-task model selection, not a replacement:
|
||||||
|
- Selecting a preset fills in the task's executor and validator model overrides
|
||||||
|
- Choosing **Custom** or manually overriding models breaks out of preset mode for that task creation flow
|
||||||
|
- Existing per-task overrides on saved tasks continue to work as before
|
||||||
|
- If a preset is later edited or deleted, already-created tasks keep their resolved model settings
|
||||||
|
|
||||||
## Per-Task Model Overrides
|
## Per-Task Model Overrides
|
||||||
|
|
||||||
The kb dashboard allows overriding the global AI model selection on a per-task basis. This enables using different models for different types of work without changing global settings.
|
The kb dashboard allows overriding the global AI model selection on a per-task basis. This enables using different models for different types of work without changing global settings.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult } from "./types.js";
|
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset } from "./types.js";
|
||||||
export { TaskStore } from "./store.js";
|
export { TaskStore } from "./store.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ describe("TaskStore", () => {
|
|||||||
expect(detail.breakIntoSubtasks).toBe(true);
|
expect(detail.breakIntoSubtasks).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists modelPresetId when provided during task creation", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Preset task",
|
||||||
|
modelPresetId: "budget",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.modelPresetId).toBe("budget");
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.modelPresetId).toBe("budget");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves breakIntoSubtasks unset by default", async () => {
|
it("leaves breakIntoSubtasks unset by default", async () => {
|
||||||
const task = await store.createTask({
|
const task = await store.createTask({
|
||||||
description: "Regular task",
|
description: "Regular task",
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
column: input.column || "triage",
|
column: input.column || "triage",
|
||||||
dependencies: input.dependencies || [],
|
dependencies: input.dependencies || [],
|
||||||
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
|
breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined,
|
||||||
|
modelPresetId: input.modelPresetId,
|
||||||
modelProvider: input.modelProvider,
|
modelProvider: input.modelProvider,
|
||||||
modelId: input.modelId,
|
modelId: input.modelId,
|
||||||
validatorModelProvider: input.validatorModelProvider,
|
validatorModelProvider: input.validatorModelProvider,
|
||||||
@@ -349,6 +350,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
title: sourceTask.title,
|
title: sourceTask.title,
|
||||||
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
|
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
|
||||||
column: "triage",
|
column: "triage",
|
||||||
|
modelPresetId: sourceTask.modelPresetId,
|
||||||
dependencies: [], // Fresh task should have no dependencies
|
dependencies: [], // Fresh task should have no dependencies
|
||||||
steps: [], // Reset execution state
|
steps: [], // Reset execution state
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
@@ -1053,6 +1055,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
updatedAt: task.updatedAt,
|
updatedAt: task.updatedAt,
|
||||||
columnMovedAt: task.columnMovedAt,
|
columnMovedAt: task.columnMovedAt,
|
||||||
archivedAt: task.columnMovedAt,
|
archivedAt: task.columnMovedAt,
|
||||||
|
modelPresetId: task.modelPresetId,
|
||||||
modelProvider: task.modelProvider,
|
modelProvider: task.modelProvider,
|
||||||
modelId: task.modelId,
|
modelId: task.modelId,
|
||||||
validatorModelProvider: task.validatorModelProvider,
|
validatorModelProvider: task.validatorModelProvider,
|
||||||
@@ -1794,6 +1797,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
createdAt: entry.createdAt,
|
createdAt: entry.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
columnMovedAt: entry.columnMovedAt,
|
columnMovedAt: entry.columnMovedAt,
|
||||||
|
modelPresetId: entry.modelPresetId,
|
||||||
modelProvider: entry.modelProvider,
|
modelProvider: entry.modelProvider,
|
||||||
modelId: entry.modelId,
|
modelId: entry.modelId,
|
||||||
validatorModelProvider: entry.validatorModelProvider,
|
validatorModelProvider: entry.validatorModelProvider,
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export type ColorTheme = (typeof COLOR_THEMES)[number];
|
|||||||
export type PrStatus = "open" | "closed" | "merged";
|
export type PrStatus = "open" | "closed" | "merged";
|
||||||
export type MergeStrategy = "direct" | "pull-request";
|
export type MergeStrategy = "direct" | "pull-request";
|
||||||
|
|
||||||
|
export interface ModelPreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
executorProvider?: string;
|
||||||
|
executorModelId?: string;
|
||||||
|
validatorProvider?: string;
|
||||||
|
validatorModelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PrInfo {
|
export interface PrInfo {
|
||||||
url: string;
|
url: string;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -166,6 +175,8 @@ export interface Task {
|
|||||||
log: TaskLogEntry[];
|
log: TaskLogEntry[];
|
||||||
size?: "S" | "M" | "L";
|
size?: "S" | "M" | "L";
|
||||||
reviewLevel?: number;
|
reviewLevel?: number;
|
||||||
|
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
|
||||||
|
modelPresetId?: string;
|
||||||
/** AI model provider override for the executor agent (e.g., "anthropic").
|
/** AI model provider override for the executor agent (e.g., "anthropic").
|
||||||
* Must be set together with `modelId`. When both model fields are undefined,
|
* Must be set together with `modelId`. When both model fields are undefined,
|
||||||
* the executor uses global settings defaults. */
|
* the executor uses global settings defaults. */
|
||||||
@@ -205,6 +216,8 @@ export interface TaskCreateInput {
|
|||||||
column?: Column;
|
column?: Column;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
breakIntoSubtasks?: boolean;
|
breakIntoSubtasks?: boolean;
|
||||||
|
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
|
||||||
|
modelPresetId?: string;
|
||||||
/** AI model provider override for the executor agent (e.g., "anthropic").
|
/** AI model provider override for the executor agent (e.g., "anthropic").
|
||||||
* Must be set together with `modelId`. When both model fields are undefined,
|
* Must be set together with `modelId`. When both model fields are undefined,
|
||||||
* the executor uses global settings defaults. */
|
* the executor uses global settings defaults. */
|
||||||
@@ -306,6 +319,12 @@ export interface Settings {
|
|||||||
* Must be set together with `validatorProvider`. When both are undefined,
|
* Must be set together with `validatorProvider`. When both are undefined,
|
||||||
* falls back to `defaultProvider`/`defaultModelId`. */
|
* falls back to `defaultProvider`/`defaultModelId`. */
|
||||||
validatorModelId?: string;
|
validatorModelId?: string;
|
||||||
|
/** Reusable model configuration presets for task creation. */
|
||||||
|
modelPresets?: ModelPreset[];
|
||||||
|
/** When true, task creation UIs automatically recommend/apply a preset based on task size. */
|
||||||
|
autoSelectModelPreset?: boolean;
|
||||||
|
/** Mapping of task sizes to preset IDs used for auto-selection during task creation. */
|
||||||
|
defaultPresetBySize?: { S?: string; M?: string; L?: string };
|
||||||
/** Default thinking effort level for AI agent sessions.
|
/** Default thinking effort level for AI agent sessions.
|
||||||
* Controls how much reasoning effort the model uses — higher levels
|
* Controls how much reasoning effort the model uses — higher levels
|
||||||
* produce better results but cost more. When undefined, the engine
|
* produce better results but cost more. When undefined, the engine
|
||||||
@@ -362,6 +381,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
planningModelId: undefined,
|
planningModelId: undefined,
|
||||||
validatorProvider: undefined,
|
validatorProvider: undefined,
|
||||||
validatorModelId: undefined,
|
validatorModelId: undefined,
|
||||||
|
modelPresets: [],
|
||||||
|
autoSelectModelPreset: false,
|
||||||
|
defaultPresetBySize: {},
|
||||||
defaultThinkingLevel: undefined,
|
defaultThinkingLevel: undefined,
|
||||||
autoResolveConflicts: true,
|
autoResolveConflicts: true,
|
||||||
smartConflictResolution: true,
|
smartConflictResolution: true,
|
||||||
@@ -447,7 +469,8 @@ export interface ArchivedTaskEntry {
|
|||||||
columnMovedAt?: string;
|
columnMovedAt?: string;
|
||||||
/** Timestamp when the task was archived to the log */
|
/** Timestamp when the task was archived to the log */
|
||||||
archivedAt: string;
|
archivedAt: string;
|
||||||
/** Optional: model override fields for executor and validator */
|
/** Optional: model preset and override fields for executor and validator */
|
||||||
|
modelPresetId?: string;
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
validatorModelProvider?: string;
|
validatorModelProvider?: string;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
|||||||
column,
|
column,
|
||||||
dependencies,
|
dependencies,
|
||||||
breakIntoSubtasks,
|
breakIntoSubtasks,
|
||||||
|
modelPresetId,
|
||||||
modelProvider,
|
modelProvider,
|
||||||
modelId,
|
modelId,
|
||||||
validatorModelProvider,
|
validatorModelProvider,
|
||||||
@@ -113,6 +114,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
|
|||||||
column,
|
column,
|
||||||
dependencies,
|
dependencies,
|
||||||
breakIntoSubtasks,
|
breakIntoSubtasks,
|
||||||
|
modelPresetId,
|
||||||
modelProvider,
|
modelProvider,
|
||||||
modelId,
|
modelId,
|
||||||
validatorModelProvider,
|
validatorModelProvider,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { Brain, Link, Lightbulb, ListTree } from "lucide-react";
|
import { Brain, Link, Lightbulb, ListTree, Zap } from "lucide-react";
|
||||||
import type { Task, TaskCreateInput } from "@kb/core";
|
import type { Task, TaskCreateInput, Settings } from "@kb/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { fetchModels, uploadAttachment } from "../api";
|
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
|
||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo } from "../api";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
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 [showDeps, setShowDeps] = useState(false);
|
||||||
const [depSearch, setDepSearch] = useState("");
|
const [depSearch, setDepSearch] = useState("");
|
||||||
const [showModels, setShowModels] = useState(false);
|
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 [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
|
||||||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
|
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
|
||||||
const [validatorProvider, setValidatorProvider] = 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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -143,6 +159,8 @@ export function InlineCreateCard({
|
|||||||
|
|
||||||
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
||||||
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
||||||
|
const availablePresets = settings?.modelPresets || [];
|
||||||
|
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||||
|
|
||||||
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
|
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
|
||||||
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
|
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
|
||||||
@@ -172,7 +190,8 @@ export function InlineCreateCard({
|
|||||||
!hasExecutorOverride &&
|
!hasExecutorOverride &&
|
||||||
!hasValidatorOverride &&
|
!hasValidatorOverride &&
|
||||||
!showDeps &&
|
!showDeps &&
|
||||||
!showModels
|
!showModels &&
|
||||||
|
!showPresets
|
||||||
) {
|
) {
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
@@ -187,6 +206,7 @@ export function InlineCreateCard({
|
|||||||
hasValidatorOverride,
|
hasValidatorOverride,
|
||||||
showDeps,
|
showDeps,
|
||||||
showModels,
|
showModels,
|
||||||
|
showPresets,
|
||||||
onCancel,
|
onCancel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -239,6 +259,7 @@ export function InlineCreateCard({
|
|||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
column: "triage",
|
column: "triage",
|
||||||
dependencies: dependencies.length ? dependencies : undefined,
|
dependencies: dependencies.length ? dependencies : undefined,
|
||||||
|
modelPresetId: selectedPresetId,
|
||||||
modelProvider: hasExecutorOverride ? executorProvider : undefined,
|
modelProvider: hasExecutorOverride ? executorProvider : undefined,
|
||||||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||||
@@ -264,6 +285,7 @@ export function InlineCreateCard({
|
|||||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||||
setPendingImages([]);
|
setPendingImages([]);
|
||||||
|
|
||||||
|
setSelectedPresetId(undefined);
|
||||||
addToast(`Created ${task.id}`, "success");
|
addToast(`Created ${task.id}`, "success");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addToast(err.message, "error");
|
addToast(err.message, "error");
|
||||||
@@ -359,8 +381,10 @@ export function InlineCreateCard({
|
|||||||
setExecutorModelId(undefined);
|
setExecutorModelId(undefined);
|
||||||
setValidatorProvider(undefined);
|
setValidatorProvider(undefined);
|
||||||
setValidatorModelId(undefined);
|
setValidatorModelId(undefined);
|
||||||
|
setSelectedPresetId(undefined);
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
setShowModels(false);
|
setShowModels(false);
|
||||||
|
setShowPresets(false);
|
||||||
}, [description, onPlanningMode, addToast]);
|
}, [description, onPlanningMode, addToast]);
|
||||||
|
|
||||||
const handleSubtaskClick = useCallback(() => {
|
const handleSubtaskClick = useCallback(() => {
|
||||||
@@ -377,8 +401,10 @@ export function InlineCreateCard({
|
|||||||
setExecutorModelId(undefined);
|
setExecutorModelId(undefined);
|
||||||
setValidatorProvider(undefined);
|
setValidatorProvider(undefined);
|
||||||
setValidatorModelId(undefined);
|
setValidatorModelId(undefined);
|
||||||
|
setSelectedPresetId(undefined);
|
||||||
setShowDeps(false);
|
setShowDeps(false);
|
||||||
setShowModels(false);
|
setShowModels(false);
|
||||||
|
setShowPresets(false);
|
||||||
}, [description, onSubtaskBreakdown, addToast]);
|
}, [description, onSubtaskBreakdown, addToast]);
|
||||||
|
|
||||||
const truncate = (s: string, len: number) =>
|
const truncate = (s: string, len: number) =>
|
||||||
@@ -478,6 +504,70 @@ export function InlineCreateCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-create-model-wrap">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm inline-create-model-trigger"
|
className="btn btn-sm inline-create-model-trigger"
|
||||||
@@ -486,9 +576,11 @@ export function InlineCreateCard({
|
|||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||||
{selectedModelCount > 0
|
{selectedPreset
|
||||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
? ` ${selectedPreset.name} · ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||||
: " Models"}
|
: selectedModelCount > 0
|
||||||
|
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||||
|
: " Models"}
|
||||||
</button>
|
</button>
|
||||||
{showModels && (
|
{showModels && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
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 type { ToastType } from "../hooks/useToast";
|
||||||
import { uploadAttachment, fetchModels, updateTask } from "../api";
|
import { uploadAttachment, fetchModels, fetchSettings } from "../api";
|
||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo } from "../api";
|
||||||
import { filterModels } from "../utils/modelFilter";
|
import { filterModels } from "../utils/modelFilter";
|
||||||
|
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
|
|
||||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
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 [modelsLoading, setModelsLoading] = useState(false);
|
||||||
const [executorModel, setExecutorModel] = useState("");
|
const [executorModel, setExecutorModel] = useState("");
|
||||||
const [validatorModel, setValidatorModel] = 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 [enablePlanningMode, setEnablePlanningMode] = useState(false);
|
||||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||||
|
|
||||||
@@ -326,6 +330,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
.then((models) => setAvailableModels(models))
|
.then((models) => setAvailableModels(models))
|
||||||
.catch(() => {/* silently fail - models just won't be available */})
|
.catch(() => {/* silently fail - models just won't be available */})
|
||||||
.finally(() => setModelsLoading(false));
|
.finally(() => setModelsLoading(false));
|
||||||
|
fetchSettings()
|
||||||
|
.then((nextSettings) => setSettings(nextSettings))
|
||||||
|
.catch(() => setSettings(null));
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -341,6 +348,21 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
setHasDirtyState(isDirty);
|
setHasDirtyState(isDirty);
|
||||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode]);
|
}, [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
|
// Auto-focus description textarea when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -430,6 +452,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
setDependencies([]);
|
setDependencies([]);
|
||||||
setExecutorModel("");
|
setExecutorModel("");
|
||||||
setValidatorModel("");
|
setValidatorModel("");
|
||||||
|
setSelectedPresetId("");
|
||||||
|
setPresetMode("default");
|
||||||
setEnablePlanningMode(false);
|
setEnablePlanningMode(false);
|
||||||
setHasDirtyState(false);
|
setHasDirtyState(false);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -452,6 +476,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
setDependencies([]);
|
setDependencies([]);
|
||||||
setExecutorModel("");
|
setExecutorModel("");
|
||||||
setValidatorModel("");
|
setValidatorModel("");
|
||||||
|
setSelectedPresetId("");
|
||||||
|
setPresetMode("default");
|
||||||
setEnablePlanningMode(false);
|
setEnablePlanningMode(false);
|
||||||
|
|
||||||
// Close modal and trigger planning mode
|
// Close modal and trigger planning mode
|
||||||
@@ -466,11 +492,19 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// Create the base task
|
// Create the base task
|
||||||
|
const executorSlashIdx = executorModel.indexOf("/");
|
||||||
|
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||||
|
|
||||||
const task = await onCreateTask({
|
const task = await onCreateTask({
|
||||||
title: undefined,
|
title: undefined,
|
||||||
description: trimmedDesc,
|
description: trimmedDesc,
|
||||||
column: "triage",
|
column: "triage",
|
||||||
dependencies: dependencies.length ? dependencies : undefined,
|
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
|
// 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
|
// Clean up
|
||||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||||
setPendingImages([]);
|
setPendingImages([]);
|
||||||
@@ -517,6 +529,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
setDependencies([]);
|
setDependencies([]);
|
||||||
setExecutorModel("");
|
setExecutorModel("");
|
||||||
setValidatorModel("");
|
setValidatorModel("");
|
||||||
|
setSelectedPresetId("");
|
||||||
|
setPresetMode("default");
|
||||||
setEnablePlanningMode(false);
|
setEnablePlanningMode(false);
|
||||||
|
|
||||||
addToast(`Created ${task.id}`, "success");
|
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>
|
<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">
|
<div className="model-select-row">
|
||||||
<label htmlFor="executor-model" className="model-select-label">Executor</label>
|
<label htmlFor="executor-model" className="model-select-label">Executor</label>
|
||||||
<ModelCombobox
|
<ModelCombobox
|
||||||
id="executor-model"
|
id="executor-model"
|
||||||
label="Executor Model"
|
label="Executor Model"
|
||||||
value={executorModel}
|
value={executorModel}
|
||||||
onChange={setExecutorModel}
|
onChange={(value) => {
|
||||||
|
setPresetMode("custom");
|
||||||
|
setSelectedPresetId("");
|
||||||
|
setExecutorModel(value);
|
||||||
|
}}
|
||||||
models={availableModels}
|
models={availableModels}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || presetMode === "preset"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="model-select-row">
|
<div className="model-select-row">
|
||||||
@@ -680,9 +747,13 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
|||||||
id="validator-model"
|
id="validator-model"
|
||||||
label="Validator Model"
|
label="Validator Model"
|
||||||
value={validatorModel}
|
value={validatorModel}
|
||||||
onChange={setValidatorModel}
|
onChange={(value) => {
|
||||||
|
setPresetMode("custom");
|
||||||
|
setSelectedPresetId("");
|
||||||
|
setValidatorModel(value);
|
||||||
|
}}
|
||||||
models={availableModels}
|
models={availableModels}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || presetMode === "preset"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { THINKING_LEVELS } from "@kb/core";
|
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 { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
|
||||||
import type { AuthProvider, ModelInfo } from "../api";
|
import type { AuthProvider, ModelInfo } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ThemeSelector } from "./ThemeSelector";
|
import { ThemeSelector } from "./ThemeSelector";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
import { applyPresetToSelection, generatePresetId, validatePresetId } from "../utils/modelPresets";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings sections configuration.
|
* Settings sections configuration.
|
||||||
@@ -29,6 +30,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
|||||||
const SETTINGS_SECTIONS = [
|
const SETTINGS_SECTIONS = [
|
||||||
{ id: "general", label: "General" },
|
{ id: "general", label: "General" },
|
||||||
{ id: "model", label: "Model" },
|
{ id: "model", label: "Model" },
|
||||||
|
{ id: "model-presets", label: "Model Presets" },
|
||||||
{ id: "appearance", label: "Appearance" },
|
{ id: "appearance", label: "Appearance" },
|
||||||
{ id: "scheduling", label: "Scheduling" },
|
{ id: "scheduling", label: "Scheduling" },
|
||||||
{ id: "worktrees", label: "Worktrees" },
|
{ id: "worktrees", label: "Worktrees" },
|
||||||
@@ -81,6 +83,9 @@ export function SettingsModal({
|
|||||||
|
|
||||||
// Test notification state
|
// Test notification state
|
||||||
const [testNotificationLoading, setTestNotificationLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchSettings()
|
fetchSettings()
|
||||||
@@ -208,7 +213,7 @@ export function SettingsModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (prefixError) return;
|
if (prefixError || presetDraft) return;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...form,
|
||||||
@@ -223,6 +228,44 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
}, [form, prefixError, onClose, addToast]);
|
}, [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 = () => {
|
const renderSectionFields = () => {
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case "general":
|
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":
|
case "appearance":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { InlineCreateCard } from "../InlineCreateCard";
|
import { InlineCreateCard } from "../InlineCreateCard";
|
||||||
import type { Task, Column } from "@kb/core";
|
import type { Task, Column } from "@kb/core";
|
||||||
import { fetchModels } from "../../api";
|
import { fetchModels, fetchSettings } from "../../api";
|
||||||
import type { ModelInfo } from "../../api";
|
import type { ModelInfo } from "../../api";
|
||||||
|
|
||||||
// Mock lucide-react
|
// Mock lucide-react
|
||||||
@@ -16,11 +16,17 @@ vi.mock("lucide-react", () => ({
|
|||||||
Terminal: () => null,
|
Terminal: () => null,
|
||||||
Lightbulb: () => null,
|
Lightbulb: () => null,
|
||||||
ListTree: () => null,
|
ListTree: () => null,
|
||||||
|
Zap: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the api module
|
// Mock the api module
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchModels: vi.fn().mockResolvedValue([]),
|
fetchModels: vi.fn().mockResolvedValue([]),
|
||||||
|
fetchSettings: vi.fn().mockResolvedValue({
|
||||||
|
modelPresets: [],
|
||||||
|
autoSelectModelPreset: false,
|
||||||
|
defaultPresetBySize: {},
|
||||||
|
}),
|
||||||
uploadAttachment: vi.fn(),
|
uploadAttachment: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -85,6 +91,11 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
|
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValue({
|
||||||
|
modelPresets: [],
|
||||||
|
autoSelectModelPreset: false,
|
||||||
|
defaultPresetBySize: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("InlineCreateCard blur-to-cancel", () => {
|
describe("InlineCreateCard blur-to-cancel", () => {
|
||||||
@@ -266,6 +277,48 @@ describe("InlineCreateCard model selector", () => {
|
|||||||
expect(props.onCancel).not.toHaveBeenCalled();
|
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", () => {
|
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||||
const { props } = renderCard();
|
const { props } = renderCard();
|
||||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
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 { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { NewTaskModal } from "../NewTaskModal";
|
import { NewTaskModal } from "../NewTaskModal";
|
||||||
import type { Task, Column } from "@kb/core";
|
import type { Task, Column } from "@kb/core";
|
||||||
|
import { fetchSettings } from "../../api";
|
||||||
|
|
||||||
// Mock the api module
|
// Mock the api module
|
||||||
vi.mock("../../api", () => ({
|
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: "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 },
|
{ 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 {
|
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", () => {
|
it("toggles planning mode checkbox", () => {
|
||||||
renderNewTaskModal();
|
renderNewTaskModal();
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const defaultSettings: Settings = {
|
|||||||
buildCommand: "",
|
buildCommand: "",
|
||||||
autoResolveConflicts: true,
|
autoResolveConflicts: true,
|
||||||
smartConflictResolution: true,
|
smartConflictResolution: true,
|
||||||
|
modelPresets: [],
|
||||||
|
autoSelectModelPreset: false,
|
||||||
|
defaultPresetBySize: {},
|
||||||
ntfyEnabled: false,
|
ntfyEnabled: false,
|
||||||
ntfyTopic: undefined,
|
ntfyTopic: undefined,
|
||||||
taskStuckTimeoutMs: undefined,
|
taskStuckTimeoutMs: undefined,
|
||||||
@@ -373,6 +376,55 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getAllByText("Model").length).toBeGreaterThanOrEqual(1);
|
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 () => {
|
it("shows model selector with available models grouped by provider", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
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 });
|
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 () => {
|
it("returns 500 on store update error", async () => {
|
||||||
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));
|
(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 multer from "multer";
|
||||||
import { createReadStream, existsSync } from "node:fs";
|
import { createReadStream, existsSync } from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
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 { 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 type { ServerOptions } from "./server.js";
|
||||||
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
|
||||||
@@ -74,6 +74,74 @@ function normalizeModelSelectionPair(provider: string | undefined, modelId: stri
|
|||||||
return { provider, modelId };
|
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 Detection ──────────────────────────────────────────
|
||||||
|
|
||||||
/** Git remote info returned by the remotes endpoint */
|
/** 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.
|
// These are computed server-side and injected only on GET /settings.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { githubTokenConfigured, ...clientSettings } = req.body;
|
const { githubTokenConfigured, ...clientSettings } = req.body;
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
|
||||||
|
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
|
||||||
|
}
|
||||||
|
|
||||||
const settings = await store.updateSettings(clientSettings);
|
const settings = await store.updateSettings(clientSettings);
|
||||||
res.json(settings);
|
res.json(settings);
|
||||||
} catch (err: any) {
|
} 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,
|
column,
|
||||||
dependencies,
|
dependencies,
|
||||||
breakIntoSubtasks,
|
breakIntoSubtasks,
|
||||||
|
modelPresetId,
|
||||||
modelProvider,
|
modelProvider,
|
||||||
modelId,
|
modelId,
|
||||||
validatorModelProvider,
|
validatorModelProvider,
|
||||||
@@ -980,6 +1057,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
column,
|
column,
|
||||||
dependencies,
|
dependencies,
|
||||||
breakIntoSubtasks,
|
breakIntoSubtasks,
|
||||||
|
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
||||||
modelProvider: executorModel.provider,
|
modelProvider: executorModel.provider,
|
||||||
modelId: executorModel.modelId,
|
modelId: executorModel.modelId,
|
||||||
validatorModelProvider: validatorModel.provider,
|
validatorModelProvider: validatorModel.provider,
|
||||||
|
|||||||
Reference in New Issue
Block a user