204 lines
6.6 KiB
TypeScript
204 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useCallback, useMemo } from "react";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { Combobox, type ComboboxOption } from "@/components/combobox";
|
|
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
|
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
|
|
import type { Agent, AgentProfile, AvailableAgent } from "@/lib/types/http";
|
|
import { setSessionModel } from "@/lib/api/domains/session-api";
|
|
import type { SessionModelEntry } from "@/lib/state/slices/session-runtime/types";
|
|
|
|
type ModelSelectorProps = {
|
|
sessionId: string | null;
|
|
};
|
|
|
|
type ModelOption = {
|
|
id: string;
|
|
name: string;
|
|
provider?: string;
|
|
context_window?: number;
|
|
is_default?: boolean;
|
|
description?: string;
|
|
usageMultiplier?: string;
|
|
};
|
|
|
|
function resolveSnapshotModel(snapshot: unknown): string | null {
|
|
if (!snapshot || typeof snapshot !== "object") return null;
|
|
const model = (snapshot as Record<string, unknown>).model;
|
|
return typeof model === "string" && model ? model : null;
|
|
}
|
|
|
|
function resolveStaticModels(
|
|
agents: Agent[],
|
|
profileId: string | null | undefined,
|
|
availableAgents: AvailableAgent[],
|
|
): ModelOption[] {
|
|
if (!profileId) return [];
|
|
for (const agent of agents) {
|
|
const profile = agent.profiles.find((p: AgentProfile) => p.id === profileId);
|
|
if (!profile) continue;
|
|
const available = availableAgents.find((a: AvailableAgent) => a.name === agent.name);
|
|
const models = available?.model_config?.available_models ?? [];
|
|
// Static models don't include description — use model ID as subtitle when it differs from name
|
|
return models.map((m) => ({
|
|
...m,
|
|
description: m.id !== m.name ? m.id : undefined,
|
|
}));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function sessionModelsToOptions(models: SessionModelEntry[]): ModelOption[] {
|
|
return models.map((m) => ({
|
|
id: m.modelId,
|
|
name: m.name,
|
|
provider: "",
|
|
context_window: 0,
|
|
is_default: false,
|
|
description: m.description,
|
|
usageMultiplier: m.usageMultiplier,
|
|
}));
|
|
}
|
|
|
|
function buildModelOptions(
|
|
availableModels: ModelOption[],
|
|
currentModel: string | null,
|
|
): ModelOption[] {
|
|
const options = [...availableModels];
|
|
if (currentModel && !options.some((m) => m.id === currentModel)) {
|
|
options.unshift({
|
|
id: currentModel,
|
|
name: currentModel,
|
|
provider: "unknown",
|
|
context_window: 0,
|
|
is_default: false,
|
|
});
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function resolveProfileModel(profileId: string | null | undefined, agents: Agent[]): string | null {
|
|
if (!profileId) return null;
|
|
for (const agent of agents) {
|
|
const profile = agent.profiles.find((p: AgentProfile) => p.id === profileId);
|
|
if (profile?.model) return profile.model;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveCurrentModel(
|
|
activeModel: string | null,
|
|
acpCurrentModel: string | null,
|
|
snapshotModel: string | null,
|
|
profileModel: string | null,
|
|
): string | null {
|
|
return activeModel || acpCurrentModel || snapshotModel || profileModel;
|
|
}
|
|
|
|
/** Resolves available models and current model from store state. */
|
|
function useModelSelectorState(sessionId: string | null) {
|
|
useSettingsData(true);
|
|
|
|
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
|
|
const taskSessions = useAppStore((state) => state.taskSessions.items);
|
|
const activeModels = useAppStore((state) => state.activeModel.bySessionId);
|
|
const setActiveModel = useAppStore((state) => state.setActiveModel);
|
|
const { items: availableAgents } = useAvailableAgents();
|
|
const sessionModelsData = useAppStore((state) =>
|
|
sessionId ? state.sessionModels.bySessionId[sessionId] : undefined,
|
|
);
|
|
|
|
const session = sessionId ? (taskSessions[sessionId] ?? null) : null;
|
|
const snapshotModel = resolveSnapshotModel(session?.agent_profile_snapshot);
|
|
const profileModel = useMemo(
|
|
() => resolveProfileModel(session?.agent_profile_id, settingsAgents as Agent[]),
|
|
[session?.agent_profile_id, settingsAgents],
|
|
);
|
|
|
|
const usingAcpModels = !!sessionModelsData?.models?.length;
|
|
const availableModels = usingAcpModels
|
|
? sessionModelsToOptions(sessionModelsData.models)
|
|
: resolveStaticModels(settingsAgents as Agent[], session?.agent_profile_id, availableAgents);
|
|
|
|
const activeModel = sessionId ? activeModels[sessionId] || null : null;
|
|
const acpCurrentModel = sessionModelsData?.currentModelId || null;
|
|
const currentModel = resolveCurrentModel(
|
|
activeModel,
|
|
acpCurrentModel,
|
|
snapshotModel,
|
|
profileModel,
|
|
);
|
|
const modelOptions = buildModelOptions(availableModels, currentModel);
|
|
|
|
const handleModelChange = useCallback(
|
|
(sid: string, modelId: string) => {
|
|
setActiveModel(sid, modelId);
|
|
setSessionModel(sid, modelId).catch((err) => {
|
|
console.error("[ModelSelector] set-model API failed:", err);
|
|
});
|
|
},
|
|
[setActiveModel],
|
|
);
|
|
|
|
return { currentModel, modelOptions, handleModelChange };
|
|
}
|
|
|
|
function modelToComboboxOption(model: ModelOption): ComboboxOption {
|
|
return {
|
|
value: model.id,
|
|
label: model.name,
|
|
description: model.description,
|
|
renderLabel: () => (
|
|
<>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate">{model.name}</div>
|
|
{model.description && (
|
|
<div className="text-xs text-muted-foreground truncate" title={model.description}>
|
|
{model.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{model.usageMultiplier && (
|
|
<span className="text-xs text-muted-foreground shrink-0">{model.usageMultiplier}</span>
|
|
)}
|
|
</>
|
|
),
|
|
};
|
|
}
|
|
|
|
export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSelectorProps) {
|
|
const { currentModel, modelOptions, handleModelChange } = useModelSelectorState(sessionId);
|
|
|
|
const comboboxOptions = useMemo(() => modelOptions.map(modelToComboboxOption), [modelOptions]);
|
|
|
|
const onValueChange = useCallback(
|
|
(value: string) => {
|
|
// Don't allow deselecting — always keep a model selected
|
|
if (!value || !sessionId) return;
|
|
handleModelChange(sessionId, value);
|
|
},
|
|
[sessionId, handleModelChange],
|
|
);
|
|
|
|
if (!sessionId || !currentModel) return null;
|
|
|
|
return (
|
|
<Combobox
|
|
options={comboboxOptions}
|
|
value={currentModel}
|
|
onValueChange={onValueChange}
|
|
placeholder="Select model..."
|
|
searchPlaceholder="Filter models..."
|
|
emptyMessage="No models found."
|
|
showSearch={modelOptions.length > 5}
|
|
dropdownLabel="Available Models"
|
|
triggerClassName="h-7 gap-1 px-2 text-xs w-auto hover:bg-muted/40 whitespace-nowrap"
|
|
className="min-w-[280px]"
|
|
plainTrigger
|
|
popoverSide="top"
|
|
popoverAlign="end"
|
|
/>
|
|
);
|
|
});
|