- Bump workflow model lane changeset to minor - Clear pending workflow model edits after value reload failures - Exclude Vite source module requests with query strings from API proxy
727 lines
30 KiB
TypeScript
727 lines
30 KiB
TypeScript
/**
|
|
* Project Models section (U9 / KTD-10).
|
|
*
|
|
* Project-scoped model configuration. The project DEFAULT model lane still saves
|
|
* as project settings. The common workflow model lanes (Plan/Triage, Executor,
|
|
* Reviewer) are now proxy-edited here for the active default workflow while
|
|
* persisting through workflow setting values, not tombstoned project keys.
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { ModelPreset, Settings } from "@fusion/core";
|
|
import {
|
|
ApiRequestError,
|
|
fetchWorkflowSettingValues,
|
|
updateWorkflowSettingValues,
|
|
type ModelInfo,
|
|
type WorkflowSettingRejection,
|
|
type WorkflowSettingValuesPayload,
|
|
} from "../../../api";
|
|
import { CustomModelDropdown } from "../../CustomModelDropdown";
|
|
import { applyPresetToSelection } from "../../../utils/modelPresets";
|
|
import type { ToastType } from "../../../hooks/useToast";
|
|
import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context";
|
|
|
|
type LaneStatus = "inherited" | "overridden";
|
|
|
|
export interface ProjectModelsSectionModelProps {
|
|
modelLanes: ModelLane[];
|
|
getLaneStatus: (lane: ModelLane) => LaneStatus;
|
|
getLaneValue: (lane: ModelLane) => string;
|
|
updateLaneValue: (lane: ModelLane, value: string) => void;
|
|
resetLaneValue: (lane: ModelLane) => void;
|
|
availableModels: ModelInfo[];
|
|
modelsLoading: boolean;
|
|
favoriteProviders: string[];
|
|
favoriteModels: string[];
|
|
onToggleFavorite: (provider: string) => void;
|
|
onToggleModelFavorite: (modelId: string) => void;
|
|
editingPresetId: string | null;
|
|
setEditingPresetId: (id: string | null) => void;
|
|
presetDraft: ModelPreset | null;
|
|
setPresetDraft: (updater: ModelPreset | null | ((prev: ModelPreset | null) => ModelPreset | null)) => void;
|
|
onSavePresetDraft: () => void;
|
|
confirmDelete: (options: { title: string; message: string; danger?: boolean }) => Promise<boolean>;
|
|
}
|
|
|
|
export interface ProjectModelsSectionProps extends SectionBaseProps {
|
|
scopeBanner: ReactNode;
|
|
models: ProjectModelsSectionModelProps;
|
|
projectId?: string;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
onOpenWorkflowSettings?: () => void;
|
|
}
|
|
|
|
interface WorkflowModelLane {
|
|
id: "planning" | "execution" | "validator";
|
|
label: string;
|
|
providerKey: string;
|
|
modelKey: string;
|
|
help: string;
|
|
}
|
|
|
|
const WORKFLOW_MODEL_LANES: WorkflowModelLane[] = [
|
|
{
|
|
id: "planning",
|
|
label: "Plan/Triage Model",
|
|
providerKey: "planningProvider",
|
|
modelKey: "planningModelId",
|
|
help: "Used when Fusion plans, breaks down, or triages tasks for this workflow.",
|
|
},
|
|
{
|
|
id: "execution",
|
|
label: "Executor Model",
|
|
providerKey: "executionProvider",
|
|
modelKey: "executionModelId",
|
|
help: "Used by implementation agents running this workflow.",
|
|
},
|
|
{
|
|
id: "validator",
|
|
label: "Reviewer Model",
|
|
providerKey: "validatorProvider",
|
|
modelKey: "validatorModelId",
|
|
help: "Used by review and validation agents for this workflow.",
|
|
},
|
|
];
|
|
|
|
function splitModelValue(value: string): { provider: string | null; modelId: string | null } {
|
|
if (!value) return { provider: null, modelId: null };
|
|
const slashIdx = value.indexOf("/");
|
|
if (slashIdx <= 0) return { provider: null, modelId: null };
|
|
return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) };
|
|
}
|
|
|
|
export function ProjectModelsSection({
|
|
scopeBanner,
|
|
form,
|
|
setForm,
|
|
models,
|
|
projectId,
|
|
addToast,
|
|
onOpenWorkflowSettings,
|
|
}: ProjectModelsSectionProps) {
|
|
const { t } = useTranslation("app");
|
|
const {
|
|
modelLanes,
|
|
getLaneStatus,
|
|
getLaneValue,
|
|
updateLaneValue,
|
|
resetLaneValue,
|
|
availableModels,
|
|
modelsLoading,
|
|
favoriteProviders,
|
|
favoriteModels,
|
|
onToggleFavorite,
|
|
onToggleModelFavorite,
|
|
editingPresetId,
|
|
setEditingPresetId,
|
|
presetDraft,
|
|
setPresetDraft,
|
|
onSavePresetDraft,
|
|
confirmDelete,
|
|
} = models;
|
|
|
|
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));
|
|
const defaultWorkflowId = useMemo(() => {
|
|
const raw = typeof form.defaultWorkflowId === "string" ? form.defaultWorkflowId.trim() : "";
|
|
return raw || "builtin:coding";
|
|
}, [form.defaultWorkflowId]);
|
|
const [workflowPayload, setWorkflowPayload] = useState<WorkflowSettingValuesPayload | null>(null);
|
|
const [workflowPending, setWorkflowPending] = useState<Record<string, unknown>>({});
|
|
const [workflowRejections, setWorkflowRejections] = useState<Record<string, WorkflowSettingRejection>>({});
|
|
const [resolvedWorkflowId, setResolvedWorkflowId] = useState(defaultWorkflowId);
|
|
const [workflowLoading, setWorkflowLoading] = useState(false);
|
|
const [workflowSaving, setWorkflowSaving] = useState(false);
|
|
const reqSeq = useRef(0);
|
|
|
|
const loadWorkflowValues = useCallback(async () => {
|
|
const seq = ++reqSeq.current;
|
|
if (!projectId) {
|
|
setWorkflowPayload(null);
|
|
setWorkflowPending({});
|
|
setWorkflowRejections({});
|
|
setResolvedWorkflowId(defaultWorkflowId);
|
|
return;
|
|
}
|
|
setWorkflowLoading(true);
|
|
try {
|
|
let targetWorkflowId = defaultWorkflowId;
|
|
let payload: WorkflowSettingValuesPayload;
|
|
try {
|
|
payload = await fetchWorkflowSettingValues(targetWorkflowId, projectId);
|
|
} catch (err) {
|
|
if (targetWorkflowId === "builtin:coding" || !(err instanceof ApiRequestError) || err.status !== 404) {
|
|
throw err;
|
|
}
|
|
targetWorkflowId = "builtin:coding";
|
|
payload = await fetchWorkflowSettingValues(targetWorkflowId, projectId);
|
|
}
|
|
if (reqSeq.current === seq) {
|
|
setWorkflowPayload(payload);
|
|
setWorkflowPending({});
|
|
setWorkflowRejections({});
|
|
setResolvedWorkflowId(targetWorkflowId);
|
|
}
|
|
} catch {
|
|
if (reqSeq.current === seq) {
|
|
setWorkflowPayload(null);
|
|
setWorkflowPending({});
|
|
setWorkflowRejections({});
|
|
setResolvedWorkflowId(defaultWorkflowId);
|
|
addToast(t("settings.models.workflowLanesLoadFailed", "Failed to load workflow model settings"), "error");
|
|
}
|
|
} finally {
|
|
if (reqSeq.current === seq) setWorkflowLoading(false);
|
|
}
|
|
}, [addToast, defaultWorkflowId, projectId, t]);
|
|
|
|
useEffect(() => {
|
|
void loadWorkflowValues();
|
|
}, [loadWorkflowValues]);
|
|
|
|
const workflowValueFor = useCallback(
|
|
(key: string): unknown => {
|
|
if (Object.prototype.hasOwnProperty.call(workflowPending, key)) {
|
|
return workflowPending[key];
|
|
}
|
|
return workflowPayload?.effective?.[key];
|
|
},
|
|
[workflowPayload, workflowPending],
|
|
);
|
|
|
|
const workflowLaneValue = useCallback(
|
|
(lane: WorkflowModelLane): string => {
|
|
const provider = workflowValueFor(lane.providerKey);
|
|
const modelId = workflowValueFor(lane.modelKey);
|
|
return typeof provider === "string" && provider && typeof modelId === "string" && modelId
|
|
? `${provider}/${modelId}`
|
|
: "";
|
|
},
|
|
[workflowValueFor],
|
|
);
|
|
|
|
const workflowLaneCustomized = useCallback(
|
|
(lane: WorkflowModelLane): boolean => {
|
|
const pendingProvider = workflowPending[lane.providerKey];
|
|
const pendingModel = workflowPending[lane.modelKey];
|
|
if (pendingProvider === null && pendingModel === null) return false;
|
|
if (pendingProvider !== undefined || pendingModel !== undefined) return true;
|
|
return Boolean(
|
|
workflowPayload?.stored &&
|
|
(Object.prototype.hasOwnProperty.call(workflowPayload.stored, lane.providerKey) ||
|
|
Object.prototype.hasOwnProperty.call(workflowPayload.stored, lane.modelKey)),
|
|
);
|
|
},
|
|
[workflowPayload, workflowPending],
|
|
);
|
|
|
|
const updateWorkflowLane = useCallback((lane: WorkflowModelLane, value: string) => {
|
|
const { provider, modelId } = splitModelValue(value);
|
|
setWorkflowPending((current) => ({
|
|
...current,
|
|
[lane.providerKey]: provider,
|
|
[lane.modelKey]: modelId,
|
|
}));
|
|
setWorkflowRejections((current) => {
|
|
if (!current[lane.providerKey] && !current[lane.modelKey]) return current;
|
|
const next = { ...current };
|
|
delete next[lane.providerKey];
|
|
delete next[lane.modelKey];
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const saveWorkflowModelLanes = useCallback(async () => {
|
|
if (!projectId || Object.keys(workflowPending).length === 0) return;
|
|
setWorkflowSaving(true);
|
|
try {
|
|
const payload = await updateWorkflowSettingValues(resolvedWorkflowId, workflowPending, projectId);
|
|
setWorkflowPayload(payload);
|
|
setWorkflowPending({});
|
|
setWorkflowRejections({});
|
|
addToast(t("settings.models.workflowLanesSaved", "Workflow model settings saved"), "success");
|
|
} catch (err) {
|
|
if (err instanceof ApiRequestError && err.status === 400 && err.details) {
|
|
const rejList = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? [];
|
|
if (rejList.length > 0) {
|
|
const byId: Record<string, WorkflowSettingRejection> = {};
|
|
for (const r of rejList) byId[r.settingId] = r;
|
|
setWorkflowRejections(byId);
|
|
addToast(t("settings.models.workflowLanesRejected", "Some workflow model settings were rejected"), "error");
|
|
return;
|
|
}
|
|
}
|
|
addToast(t("settings.models.workflowLanesSaveFailed", "Failed to save workflow model settings"), "error");
|
|
} finally {
|
|
setWorkflowSaving(false);
|
|
}
|
|
}, [addToast, projectId, resolvedWorkflowId, t, workflowPending]);
|
|
|
|
const workflowDirty = Object.keys(workflowPending).length > 0;
|
|
|
|
// Only the project DEFAULT model lane survives in this modal. The
|
|
// per-phase execution/planning/validator lanes, their fallbacks, and the
|
|
// title-summarizer lane were hard-moved (U4) onto the workflow settings
|
|
// mechanism — they are no longer project settings keys and must never be
|
|
// renderable or savable here (redirect stub below).
|
|
const projectModelLanes = modelLanes.filter((lane) => lane.laneId === "default");
|
|
const getProjectLaneLabel = (lane: ModelLane) => lane.laneId === "default" ? "Project Default Model" : lane.label;
|
|
const getProjectLaneHelperText = (lane: ModelLane) =>
|
|
lane.laneId === "default"
|
|
? "Project-wide default AI model used when no more specific task or project lane override is set."
|
|
: lane.helperText;
|
|
|
|
return (
|
|
<>
|
|
{scopeBanner}
|
|
|
|
{/* --- Token Cap --- */}
|
|
<h4 className="settings-section-heading">Token Cap</h4>
|
|
<div className="form-group">
|
|
<label htmlFor="tokenCap">Token Cap</label>
|
|
<div className="settings-token-cap-row">
|
|
<input
|
|
id="tokenCap"
|
|
type="number"
|
|
placeholder="No cap"
|
|
value={form.tokenCap ?? ""}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState));
|
|
}}
|
|
/>
|
|
{form.tokenCap != null && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
title="Reset to default (no cap)"
|
|
onClick={() => setForm((f) => ({ ...f, tokenCap: null } as unknown as SettingsFormState))}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
<small>Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count.</small>
|
|
</div>
|
|
|
|
{/* --- Project Model Lanes --- */}
|
|
<h4 className="settings-section-heading settings-section-heading--spaced">Model Lanes</h4>
|
|
<p className="settings-description">
|
|
Override global model settings at the project level. Each lane controls a specific AI usage context.
|
|
Unset lanes inherit from the corresponding global lane.
|
|
The Project Default Model is the fallback for this project when a more specific lane is unset.
|
|
</p>
|
|
{modelsLoading ? (
|
|
<div className="settings-empty-state">Loading available models…</div>
|
|
) : availableModels.length === 0 ? (
|
|
<div className="settings-empty-state settings-muted">
|
|
No models available. Configure authentication first.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{projectModelLanes.map((lane) => {
|
|
const status = getLaneStatus(lane);
|
|
const value = getLaneValue(lane);
|
|
const isOverridden = status === "overridden";
|
|
const laneLabel = getProjectLaneLabel(lane);
|
|
|
|
return (
|
|
<div className="form-group" key={lane.laneId}>
|
|
<div className="settings-model-lane-label-row">
|
|
<label htmlFor={`${lane.laneId}Model`}>{laneLabel}</label>
|
|
<span
|
|
className={`settings-lane-badge ${isOverridden ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
|
|
title={isOverridden ? "Explicitly set for this project" : "Inherited from global settings"}
|
|
>
|
|
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
|
|
</span>
|
|
</div>
|
|
<div className="settings-model-lane-control-row">
|
|
<div className="settings-model-lane-control-main">
|
|
<CustomModelDropdown
|
|
id={`${lane.laneId}Model`}
|
|
label={laneLabel}
|
|
models={availableModels}
|
|
value={value}
|
|
onChange={(val) => updateLaneValue(lane, val)}
|
|
placeholder={lane.laneId === "default" ? "Use global default" : "Use global"}
|
|
favoriteProviders={favoriteProviders}
|
|
onToggleFavorite={onToggleFavorite}
|
|
favoriteModels={favoriteModels}
|
|
onToggleModelFavorite={onToggleModelFavorite}
|
|
/>
|
|
</div>
|
|
{isOverridden && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
title="Reset to inherit from global"
|
|
onClick={() => resetLaneValue(lane)}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
<small>
|
|
{getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}.
|
|
</small>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{/* --- Default workflow model lanes (workflow setting values) --- */}
|
|
<h4 className="settings-section-heading settings-section-heading--spaced">Default workflow model lanes</h4>
|
|
<p className="settings-description">
|
|
These controls edit model values on this project's default workflow ({resolvedWorkflowId}).
|
|
They use workflow settings as the source of truth.
|
|
</p>
|
|
{!projectId ? (
|
|
<div className="settings-empty-state settings-muted">
|
|
Open a project to edit workflow model lanes.
|
|
</div>
|
|
) : modelsLoading || workflowLoading ? (
|
|
<div className="settings-empty-state">Loading workflow model settings…</div>
|
|
) : availableModels.length === 0 ? (
|
|
<div className="settings-empty-state settings-muted">
|
|
No models available. Configure authentication first.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{WORKFLOW_MODEL_LANES.map((lane) => {
|
|
const value = workflowLaneValue(lane);
|
|
const customized = workflowLaneCustomized(lane);
|
|
const rejection = workflowRejections[lane.providerKey] ?? workflowRejections[lane.modelKey];
|
|
return (
|
|
<div className="form-group" key={lane.id} data-testid={`workflow-model-lane-${lane.id}`}>
|
|
<div className="settings-model-lane-label-row">
|
|
<label htmlFor={`workflow-${lane.id}-model`}>{lane.label}</label>
|
|
<span
|
|
className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
|
|
title={customized ? "Explicitly set on the default workflow" : "Inherited through workflow/global defaults"}
|
|
>
|
|
{customized ? "Override (Workflow)" : "Inherited"}
|
|
</span>
|
|
</div>
|
|
<div className="settings-model-lane-control-row">
|
|
<div className="settings-model-lane-control-main">
|
|
<CustomModelDropdown
|
|
id={`workflow-${lane.id}-model`}
|
|
label={lane.label}
|
|
models={availableModels}
|
|
value={value}
|
|
onChange={(val) => updateWorkflowLane(lane, val)}
|
|
placeholder="Use workflow/global default"
|
|
defaultOptionLabel="Use workflow/global default"
|
|
favoriteProviders={favoriteProviders}
|
|
onToggleFavorite={onToggleFavorite}
|
|
favoriteModels={favoriteModels}
|
|
onToggleModelFavorite={onToggleModelFavorite}
|
|
/>
|
|
</div>
|
|
{customized && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm"
|
|
title="Reset to inherit"
|
|
onClick={() => updateWorkflowLane(lane, "")}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
{rejection ? (
|
|
<small className="field-error" role="alert" data-testid={`workflow-model-lane-error-${lane.id}`}>
|
|
{rejection.message}
|
|
</small>
|
|
) : null}
|
|
<small>{lane.help}</small>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="settings-model-lane-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
data-testid="save-workflow-model-lanes"
|
|
disabled={!workflowDirty || workflowSaving}
|
|
onClick={() => void saveWorkflowModelLanes()}
|
|
>
|
|
Save workflow models
|
|
</button>
|
|
{onOpenWorkflowSettings && (
|
|
<button type="button" className="btn btn-ghost btn-sm" onClick={onOpenWorkflowSettings}>
|
|
Advanced workflow policy
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* --- Model Presets --- */}
|
|
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4>
|
|
<div className="form-group settings-model-presets">
|
|
<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="settings-preset-item">
|
|
<div className="settings-preset-item-meta">
|
|
<strong>{preset.name}</strong>
|
|
<span className="settings-muted settings-preset-summary">{summary}</span>
|
|
</div>
|
|
<div className="settings-preset-item-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => {
|
|
setEditingPresetId(preset.id);
|
|
setPresetDraft({ ...preset });
|
|
}}
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={async () => {
|
|
if (inUsePresetIds.has(preset.id)) {
|
|
const shouldDelete = await confirmDelete({
|
|
title: t("settings.models.deletePresetTitle", "Delete Preset"),
|
|
message: t("settings.models.deletePresetMessage", "Preset \"{{name}}\" is used in auto-selection. Delete it anyway?", { name: preset.name }),
|
|
danger: true,
|
|
});
|
|
if (!shouldDelete) {
|
|
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);
|
|
}
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{!presetDraft ? (
|
|
<div className="settings-preset-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => {
|
|
setEditingPresetId(null);
|
|
setPresetDraft({ id: "", name: "", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined });
|
|
}}
|
|
>
|
|
Add Preset
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{presetDraft ? (
|
|
<div className="form-group settings-preset-editor">
|
|
<label>Preset editor</label>
|
|
<div className="settings-preset-editor-fields">
|
|
<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 } : current);
|
|
}}
|
|
/>
|
|
</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"
|
|
favoriteProviders={favoriteProviders}
|
|
onToggleFavorite={onToggleFavorite}
|
|
favoriteModels={favoriteModels}
|
|
onToggleModelFavorite={onToggleModelFavorite}
|
|
/>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="preset-validator-model">Reviewer model</label>
|
|
<CustomModelDropdown
|
|
id="preset-validator-model"
|
|
label="Preset reviewer 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"
|
|
favoriteProviders={favoriteProviders}
|
|
onToggleFavorite={onToggleFavorite}
|
|
favoriteModels={favoriteModels}
|
|
onToggleModelFavorite={onToggleModelFavorite}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="modal-actions settings-preset-editor-actions">
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={onSavePresetDraft}>{t("settings.models.savePreset", "Save preset")}</button>
|
|
<button type="button" className="btn btn-sm" onClick={() => { setEditingPresetId(null); setPresetDraft(null); }}>{t("settings.actions.cancel", "Cancel")}</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="form-group settings-preset-auto-select">
|
|
<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 ? (
|
|
<div className="settings-preset-size-grid">
|
|
{(["S", "M", "L"] as const).map((sizeKey) => (
|
|
<div className="form-group settings-preset-size-row" 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>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* --- AI Title and Git Commit Message Summarization --- */}
|
|
<h4 className="settings-section-heading settings-section-heading--spaced">
|
|
AI Title and Git Commit Message Summarization
|
|
</h4>
|
|
<p className="settings-description">
|
|
Configures the model used for two short-summary jobs:
|
|
auto-generating task titles from long descriptions, and
|
|
generating merge commit summaries from step commits and diff stats.
|
|
</p>
|
|
<div className="form-group">
|
|
<label htmlFor="autoSummarizeTitles" className="checkbox-label">
|
|
<input
|
|
id="autoSummarizeTitles"
|
|
type="checkbox"
|
|
checked={form.autoSummarizeTitles || false}
|
|
onChange={(e) => setForm((f) => ({ ...f, autoSummarizeTitles: e.target.checked }))}
|
|
/>
|
|
Auto-summarize long descriptions as titles
|
|
</label>
|
|
<small>
|
|
When enabled, tasks created without a title but with descriptions over 200 characters
|
|
will automatically get an AI-generated title (max 60 characters). The same model is
|
|
also used to generate fallback merge commit message bodies when the branch's commit
|
|
log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue
|
|
titles when a tracked task has no title yet.
|
|
</small>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="useAiMergeCommitSummary" className="checkbox-label">
|
|
<input
|
|
id="useAiMergeCommitSummary"
|
|
type="checkbox"
|
|
checked={form.useAiMergeCommitSummary || false}
|
|
onChange={(e) => setForm((f) => ({ ...f, useAiMergeCommitSummary: e.target.checked }))}
|
|
/>
|
|
AI merge commit summaries
|
|
</label>
|
|
<small>
|
|
When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model.
|
|
</small>
|
|
</div>
|
|
|
|
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (
|
|
<p className="settings-description">
|
|
{t(
|
|
"settings.movedStub.summarizerModelInline",
|
|
"The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it.",
|
|
)}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default ProjectModelsSection;
|