feat: add support for model variants across chat components and session management

This commit is contained in:
Bohdan Triapitsyn
2026-01-08 14:48:06 +02:00
parent 8ff7b61b8a
commit 8e777a94c2
11 changed files with 472 additions and 35 deletions
+1 -1
View File
@@ -2858,7 +2858,7 @@ dependencies = [
[[package]] [[package]]
name = "openchamber-desktop" name = "openchamber-desktop"
version = "1.4.3" version = "1.4.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -145,7 +145,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText); const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
const pendingInputText = useSessionStore((state) => state.pendingInputText); const pendingInputText = useSessionStore((state) => state.pendingInputText);
const { currentProviderId, currentModelId, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
const agents = getVisibleAgents(); const agents = getVisibleAgents();
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen } = useUIStore(); const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen } = useUIStore();
const { working } = useAssistantStatus(); const { working } = useAssistantStatus();
@@ -473,7 +473,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
currentAgentName, currentAgentName,
primaryAttachments, primaryAttachments,
agentMentionName, agentMentionName,
additionalParts.length > 0 ? additionalParts : undefined additionalParts.length > 0 ? additionalParts : undefined,
currentVariant
).catch((error: unknown) => { ).catch((error: unknown) => {
const rawMessage = const rawMessage =
error instanceof Error error instanceof Error
@@ -165,11 +165,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const mode = getMessageInfoProp(previousMessage.info, 'mode'); const mode = getMessageInfoProp(previousMessage.info, 'mode');
const providerID = getMessageInfoProp(previousMessage.info, 'providerID'); const providerID = getMessageInfoProp(previousMessage.info, 'providerID');
const modelID = getMessageInfoProp(previousMessage.info, 'modelID'); const modelID = getMessageInfoProp(previousMessage.info, 'modelID');
const variant = getMessageInfoProp(previousMessage.info, 'variant');
const resolvedAgent = typeof mode === 'string' && mode.trim().length > 0 ? mode : undefined; const resolvedAgent = typeof mode === 'string' && mode.trim().length > 0 ? mode : undefined;
const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined; const resolvedProvider = typeof providerID === 'string' && providerID.trim().length > 0 ? providerID : undefined;
const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined; const resolvedModel = typeof modelID === 'string' && modelID.trim().length > 0 ? modelID : undefined;
const resolvedVariant = typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined;
if (!resolvedAgent && !resolvedProvider && !resolvedModel) { if (!resolvedAgent && !resolvedProvider && !resolvedModel && !resolvedVariant) {
return null; return null;
} }
@@ -177,6 +179,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
agentName: resolvedAgent, agentName: resolvedAgent,
providerId: resolvedProvider, providerId: resolvedProvider,
modelId: resolvedModel, modelId: resolvedModel,
variant: resolvedVariant,
}; };
}, [isUser, previousMessage]); }, [isUser, previousMessage]);
@@ -267,6 +270,23 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return undefined; return undefined;
}, [isUser, providerID, modelID, providers]); }, [isUser, providerID, modelID, providers]);
const modelHasVariants = React.useMemo(() => {
if (isUser) return false;
if (!providerID || !modelID) return false;
const provider = providers.find((p) => p.id === providerID);
if (!provider?.models || !Array.isArray(provider.models)) {
return false;
}
const model = provider.models.find((m: Record<string, unknown>) => (m as Record<string, unknown>).id === modelID) as
| { variants?: Record<string, unknown> }
| undefined;
const variants = model?.variants;
return Boolean(variants && Object.keys(variants).length > 0);
}, [isUser, modelID, providerID, providers]);
const displayAgentName = useStickyDisplayValue<string>(agentName); const displayAgentName = useStickyDisplayValue<string>(agentName);
const displayProviderIDValue = useStickyDisplayValue<string>(providerID ?? undefined); const displayProviderIDValue = useStickyDisplayValue<string>(providerID ?? undefined);
const displayModelName = useStickyDisplayValue<string>(modelName); const displayModelName = useStickyDisplayValue<string>(modelName);
@@ -508,6 +528,22 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return typeof body === 'string' && body.trim().length > 0 ? body : undefined; return typeof body === 'string' && body.trim().length > 0 ? body : undefined;
}); });
const variantFromTurnStore = useMessageStore((state) => {
if (!userMessageIdForTurn) return undefined;
const sessionId = message.info.sessionID;
if (!sessionId) return undefined;
const sessionMessages = state.messages.get(sessionId);
if (!sessionMessages) return undefined;
const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn);
if (!userMsg) return undefined;
const variant = (userMsg.info as { variant?: unknown }).variant;
return typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined;
});
const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined;
const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined;
const assistantSummaryCandidate = const assistantSummaryCandidate =
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0 typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
? turnGroupingContext.summaryBody ? turnGroupingContext.summaryBody
@@ -809,6 +845,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
providerID={headerProviderID} providerID={headerProviderID}
agentName={headerAgentName} agentName={headerAgentName}
modelName={headerModelName} modelName={headerModelName}
variant={headerVariant}
isDarkTheme={isDarkTheme} isDarkTheme={isDarkTheme}
/> />
)} )}
@@ -274,9 +274,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
providers, providers,
currentProviderId, currentProviderId,
currentModelId, currentModelId,
currentVariant,
currentAgentName, currentAgentName,
setProvider, setProvider,
setModel, setModel,
setCurrentVariant,
getCurrentModelVariants,
setAgent, setAgent,
getCurrentProvider, getCurrentProvider,
getModelMetadata, getModelMetadata,
@@ -294,6 +297,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
getSessionAgentSelection, getSessionAgentSelection,
saveAgentModelForSession, saveAgentModelForSession,
getAgentModelForSession, getAgentModelForSession,
saveAgentModelVariantForSession,
getAgentModelVariantForSession,
analyzeAndSaveExternalSessionChoices, analyzeAndSaveExternalSessionChoices,
getSessionAgentEditMode, getSessionAgentEditMode,
setSessionAgentEditMode, setSessionAgentEditMode,
@@ -308,7 +313,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const isVSCodeRuntime = useIsVSCodeRuntime(); const isVSCodeRuntime = useIsVSCodeRuntime();
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
const isCompact = isMobile; const isCompact = isMobile;
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | null>(null); const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | 'variant' | null>(null);
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null); const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null);
const [mobileModelQuery, setMobileModelQuery] = React.useState(''); const [mobileModelQuery, setMobileModelQuery] = React.useState('');
const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), []); const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), []);
@@ -498,6 +503,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const inputModalityIcons = getModalityIcons(currentMetadata, 'input'); const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
const outputModalityIcons = getModalityIcons(currentMetadata, 'output'); const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
const availableVariants = React.useMemo(() => {
const variantKey = `${currentProviderId}/${currentModelId}`;
if (!variantKey) {
return [];
}
return getCurrentModelVariants();
}, [getCurrentModelVariants, currentProviderId, currentModelId]);
const hasVariants = availableVariants.length > 0;
const costRows = [ const costRows = [
{ label: 'Input', value: formatCost(currentMetadata?.cost?.input) }, { label: 'Input', value: formatCost(currentMetadata?.cost?.input) },
{ label: 'Output', value: formatCost(currentMetadata?.cost?.output) }, { label: 'Output', value: formatCost(currentMetadata?.cost?.output) },
@@ -850,6 +864,62 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
handleAgentSwitch(); handleAgentSwitch();
}, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, agents, contextHydrated]); }, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, agents, contextHydrated]);
React.useEffect(() => {
if (!contextHydrated || !currentSessionId || !currentAgentName) {
setCurrentVariant(undefined);
return;
}
if (!currentProviderId || !currentModelId) {
setCurrentVariant(undefined);
return;
}
const savedVariant = getAgentModelVariantForSession(
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
);
if (savedVariant && !availableVariants.includes(savedVariant)) {
setCurrentVariant(undefined);
return;
}
setCurrentVariant(savedVariant);
}, [
availableVariants,
contextHydrated,
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
getAgentModelVariantForSession,
setCurrentVariant,
]);
const handleVariantSelect = React.useCallback((variant: string | undefined) => {
setCurrentVariant(variant);
if (currentSessionId && currentAgentName && currentProviderId && currentModelId) {
saveAgentModelVariantForSession(
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
variant,
);
}
}, [
currentAgentName,
currentModelId,
currentProviderId,
currentSessionId,
saveAgentModelVariantForSession,
setCurrentVariant,
]);
const handleAgentChange = (agentName: string) => { const handleAgentChange = (agentName: string) => {
try { try {
setAgent(agentName); setAgent(agentName);
@@ -1464,6 +1534,65 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
); );
}; };
const renderMobileVariantPanel = () => {
if (!isCompact || !hasVariants) return null;
const isDefault = !currentVariant;
const handleSelect = (variant: string | undefined) => {
handleVariantSelect(variant);
closeMobilePanel();
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
};
return (
<MobileOverlayPanel
open={activeMobilePanel === 'variant'}
onClose={closeMobilePanel}
title="Thinking"
>
<div className="flex flex-col gap-1.5">
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl border px-2 py-1.5 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
isDefault ? 'border-primary/30 bg-primary/10' : 'border-border/40'
)}
onClick={() => handleSelect(undefined)}
>
<span className="typography-meta font-medium text-foreground">Default</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
{availableVariants.map((variant) => {
const selected = currentVariant === variant;
const label = variant.charAt(0).toUpperCase() + variant.slice(1);
return (
<button
key={variant}
type="button"
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl border px-2 py-1.5 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
selected ? 'border-primary/30 bg-primary/10' : 'border-border/40'
)}
onClick={() => handleSelect(variant)}
>
<span className="typography-meta font-medium text-foreground">{label}</span>
{selected && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
);
})}
</div>
</MobileOverlayPanel>
);
};
const renderMobileAgentPanel = () => { const renderMobileAgentPanel = () => {
if (!isCompact) return null; if (!isCompact) return null;
@@ -2152,6 +2281,94 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
); );
}; };
const renderVariantSelector = () => {
if (!hasVariants) {
return null;
}
const displayVariant = currentVariant ?? 'Default';
const isDefault = !currentVariant;
const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]';
if (isCompact) {
return (
<button
type="button"
onClick={() => setActiveMobilePanel('variant')}
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
'cursor-pointer hover:opacity-70',
)}
>
<RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} />
<span className={cn('model-controls__variant-label', controlTextSize, 'font-medium truncate min-w-0', colorClass)}>
{displayVariant}
</span>
</button>
);
}
return (
<Tooltip delayDuration={800}>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity cursor-pointer hover:opacity-70 min-w-0',
buttonHeight,
)}
>
<RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} />
<span
className={cn(
'model-controls__variant-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktopRuntime ? 'max-w-[180px]' : undefined,
colorClass,
)}
>
{displayVariant}
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">Thinking</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">Default</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
{availableVariants.length > 0 && <DropdownMenuSeparator />}
{availableVariants.map((variant) => {
const selected = currentVariant === variant;
const label = variant.charAt(0).toUpperCase() + variant.slice(1);
return (
<DropdownMenuItem
key={variant}
className="typography-meta"
onSelect={() => handleVariantSelect(variant)}
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">{label}</span>
{selected && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="top">
<p className="typography-meta">Thinking: {displayVariant}</p>
</TooltipContent>
</Tooltip>
);
};
const renderAgentSelector = () => { const renderAgentSelector = () => {
if (!isCompact) { if (!isCompact) {
return ( return (
@@ -2344,7 +2561,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const inlineClassName = cn( const inlineClassName = cn(
'@container/model-controls flex items-center min-w-0', '@container/model-controls flex items-center min-w-0',
inlineGapClass,
// Only force full-width + truncation behaviors on true mobile layouts. // Only force full-width + truncation behaviors on true mobile layouts.
// VS Code also uses "compact" mode, but should keep its right-aligned inline sizing. // VS Code also uses "compact" mode, but should keep its right-aligned inline sizing.
isMobile && 'w-full', isMobile && 'w-full',
@@ -2357,17 +2573,18 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<div <div
className={cn( className={cn(
'flex items-center min-w-0 flex-1 justify-end', 'flex items-center min-w-0 flex-1 justify-end',
inlineGapClass,
isMobile && 'overflow-hidden' isMobile && 'overflow-hidden'
)} )}
> >
{renderVariantSelector()}
{renderModelSelector()} {renderModelSelector()}
</div>
<div className={cn('flex items-center min-w-0', inlineGapClass, isMobile && 'flex-shrink-0')}>
{renderAgentSelector()} {renderAgentSelector()}
</div> </div>
</div> </div>
{renderMobileModelPanel()} {renderMobileModelPanel()}
{renderMobileVariantPanel()}
{renderMobileAgentPanel()} {renderMobileAgentPanel()}
{renderMobileModelTooltip()} {renderMobileModelTooltip()}
{renderMobileAgentTooltip()} {renderMobileAgentTooltip()}
@@ -10,10 +10,11 @@ interface MessageHeaderProps {
providerID: string | null; providerID: string | null;
agentName: string | undefined; agentName: string | undefined;
modelName: string | undefined; modelName: string | undefined;
variant?: string;
isDarkTheme: boolean; isDarkTheme: boolean;
} }
const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agentName, modelName, isDarkTheme }) => { const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agentName, modelName, variant, isDarkTheme }) => {
const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID); const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
return ( return (
@@ -67,6 +68,25 @@ const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agent
<span className="font-medium">{agentName}</span> <span className="font-medium">{agentName}</span>
</div> </div>
)} )}
{!isUser && variant && (
<div
className={cn(
'flex items-center gap-1 px-1.5 py-0 rounded',
'agent-badge typography-meta',
variant === 'Default' ? undefined : 'agent-info'
)}
style={
variant === 'Default'
? ({
'--agent-color': 'var(--muted-foreground)',
'--agent-color-bg': 'var(--muted-foreground)',
} as React.CSSProperties)
: undefined
}
>
<span className="font-medium">{variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+2
View File
@@ -530,6 +530,7 @@ class OpencodeService {
text: string; text: string;
prefaceText?: string; prefaceText?: string;
agent?: string; agent?: string;
variant?: string;
files?: Array<{ files?: Array<{
type: 'file'; type: 'file';
mime: string; mime: string;
@@ -638,6 +639,7 @@ class OpencodeService {
modelID: params.modelID modelID: params.modelID
}, },
agent: params.agent, agent: params.agent,
variant: params.variant,
parts parts
}); });
+87 -1
View File
@@ -24,6 +24,9 @@ interface ContextState {
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>; sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>;
// sessionId → agentName → "providerId/modelId" → variant
sessionAgentModelVariantSelections: Map<string, Map<string, Map<string, string>>>;
currentAgentContext: Map<string, string>; currentAgentContext: Map<string, string>;
sessionContextUsage: Map<string, ContextUsage>; sessionContextUsage: Map<string, ContextUsage>;
@@ -42,8 +45,12 @@ interface ContextActions {
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void; saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null; getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void;
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined;
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>; analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>;
getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => ContextUsage | null; getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => ContextUsage | null;
updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => void; updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map<string, { info: any; parts: any[] }[]>) => void;
@@ -71,6 +78,7 @@ export const useContextStore = create<ContextStore>()(
sessionModelSelections: new Map(), sessionModelSelections: new Map(),
sessionAgentSelections: new Map(), sessionAgentSelections: new Map(),
sessionAgentModelSelections: new Map(), sessionAgentModelSelections: new Map(),
sessionAgentModelVariantSelections: new Map(),
currentAgentContext: new Map(), currentAgentContext: new Map(),
sessionContextUsage: new Map(), sessionContextUsage: new Map(),
sessionAgentEditModes: new Map(), sessionAgentEditModes: new Map(),
@@ -129,8 +137,62 @@ export const useContextStore = create<ContextStore>()(
return agentMap.get(agentName) || null; return agentMap.get(agentName) || null;
}, },
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => {
set((state) => {
const newSelections = new Map(state.sessionAgentModelVariantSelections);
let agentMap = newSelections.get(sessionId);
if (!agentMap) {
agentMap = new Map();
} else {
agentMap = new Map(agentMap);
}
let modelMap = agentMap.get(agentName);
if (!modelMap) {
modelMap = new Map();
} else {
modelMap = new Map(modelMap);
}
const modelKey = `${providerId}/${modelId}`;
if (variant === undefined) {
modelMap.delete(modelKey);
if (modelMap.size === 0) {
agentMap.delete(agentName);
if (agentMap.size === 0) {
newSelections.delete(sessionId);
} else {
newSelections.set(sessionId, agentMap);
}
} else {
agentMap.set(agentName, modelMap);
newSelections.set(sessionId, agentMap);
}
} else {
modelMap.set(modelKey, variant);
agentMap.set(agentName, modelMap);
newSelections.set(sessionId, agentMap);
}
return { sessionAgentModelVariantSelections: newSelections };
});
},
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => {
const { sessionAgentModelVariantSelections } = get();
const agentMap = sessionAgentModelVariantSelections.get(sessionId);
if (!agentMap) return undefined;
const modelMap = agentMap.get(agentName);
if (!modelMap) return undefined;
return modelMap.get(`${providerId}/${modelId}`);
},
analyzeAndSaveExternalSessionChoices: async (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => { analyzeAndSaveExternalSessionChoices: async (sessionId: string, agents: any[], messages: Map<string, { info: any; parts: any[] }[]>) => {
const { saveAgentModelForSession } = get(); const { saveAgentModelForSession, saveAgentModelVariantForSession } = get();
const agentLastChoices = new Map< const agentLastChoices = new Map<
string, string,
@@ -212,6 +274,14 @@ export const useContextStore = create<ContextStore>()(
const agentName = extractAgentFromMessage(infoAny, assistantMessages.indexOf(message)); const agentName = extractAgentFromMessage(infoAny, assistantMessages.indexOf(message));
if (agentName && agents.find((a) => a.name === agentName)) { if (agentName && agents.find((a) => a.name === agentName)) {
const resolvedVariant = typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0
? infoAny.variant
: undefined;
if (resolvedVariant) {
saveAgentModelVariantForSession(sessionId, agentName, infoAny.providerID, infoAny.modelID, resolvedVariant);
}
const choice = { const choice = {
providerId: infoAny.providerID, providerId: infoAny.providerID,
modelId: infoAny.modelID, modelId: infoAny.modelID,
@@ -474,6 +544,10 @@ export const useContextStore = create<ContextStore>()(
sessionModelSelections: Array.from(state.sessionModelSelections.entries()), sessionModelSelections: Array.from(state.sessionModelSelections.entries()),
sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()), sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()),
sessionAgentModelSelections: Array.from(state.sessionAgentModelSelections.entries()).map(([sessionId, agentMap]) => [sessionId, Array.from(agentMap.entries())]), sessionAgentModelSelections: Array.from(state.sessionAgentModelSelections.entries()).map(([sessionId, agentMap]) => [sessionId, Array.from(agentMap.entries())]),
sessionAgentModelVariantSelections: Array.from(state.sessionAgentModelVariantSelections.entries()).map(([sessionId, agentMap]) => [
sessionId,
Array.from(agentMap.entries()).map(([agentName, modelMap]) => [agentName, Array.from(modelMap.entries())]),
]),
currentAgentContext: Array.from(state.currentAgentContext.entries()), currentAgentContext: Array.from(state.currentAgentContext.entries()),
sessionContextUsage: Array.from(state.sessionContextUsage.entries()), sessionContextUsage: Array.from(state.sessionContextUsage.entries()),
sessionAgentEditModes: Array.from(state.sessionAgentEditModes.entries()).map(([sessionId, agentMap]) => [sessionId, Array.from(agentMap.entries())]), sessionAgentEditModes: Array.from(state.sessionAgentEditModes.entries()).map(([sessionId, agentMap]) => [sessionId, Array.from(agentMap.entries())]),
@@ -487,6 +561,17 @@ export const useContextStore = create<ContextStore>()(
}); });
} }
const agentModelVariantSelections = new Map();
if (persistedState?.sessionAgentModelVariantSelections) {
persistedState.sessionAgentModelVariantSelections.forEach(([sessionId, agentArray]: [string, any[]]) => {
const agentMap = new Map();
agentArray.forEach(([agentName, modelArray]: [string, any[]]) => {
agentMap.set(agentName, new Map(modelArray));
});
agentModelVariantSelections.set(sessionId, agentMap);
});
}
const agentEditModes = new Map(); const agentEditModes = new Map();
if (persistedState?.sessionAgentEditModes) { if (persistedState?.sessionAgentEditModes) {
persistedState.sessionAgentEditModes.forEach(([sessionId, agentArray]: [string, any[]]) => { persistedState.sessionAgentEditModes.forEach(([sessionId, agentArray]: [string, any[]]) => {
@@ -500,6 +585,7 @@ export const useContextStore = create<ContextStore>()(
sessionModelSelections: new Map(persistedState?.sessionModelSelections || []), sessionModelSelections: new Map(persistedState?.sessionModelSelections || []),
sessionAgentSelections: new Map(persistedState?.sessionAgentSelections || []), sessionAgentSelections: new Map(persistedState?.sessionAgentSelections || []),
sessionAgentModelSelections: agentModelSelections, sessionAgentModelSelections: agentModelSelections,
sessionAgentModelVariantSelections: agentModelVariantSelections,
currentAgentContext: new Map(persistedState?.currentAgentContext || []), currentAgentContext: new Map(persistedState?.currentAgentContext || []),
sessionContextUsage: new Map(persistedState?.sessionContextUsage || []), sessionContextUsage: new Map(persistedState?.sessionContextUsage || []),
sessionAgentEditModes: agentEditModes, sessionAgentEditModes: agentEditModes,
+8 -5
View File
@@ -20,9 +20,10 @@ import { useContextStore } from "./contextStore";
// Helper function to clean up pending user message metadata // Helper function to clean up pending user message metadata
const cleanupPendingUserMessageMeta = ( const cleanupPendingUserMessageMeta = (
currentPending: Map<string, { mode?: string; providerID?: string; modelID?: string }>, currentPending: Map<string, { mode?: string; providerID?: string; modelID?: string; variant?: string }>,
sessionId: string sessionId: string
): Map<string, { mode?: string; providerID?: string; modelID?: string }> => { ): Map<string, { mode?: string; providerID?: string; modelID?: string; variant?: string }> => {
const nextPending = new Map(currentPending); const nextPending = new Map(currentPending);
nextPending.delete(sessionId); nextPending.delete(sessionId);
return nextPending; return nextPending;
@@ -338,12 +339,12 @@ interface MessageState {
sessionCompactionUntil: Map<string, number>; sessionCompactionUntil: Map<string, number>;
sessionAbortFlags: Map<string, SessionAbortRecord>; sessionAbortFlags: Map<string, SessionAbortRecord>;
pendingAssistantHeaderSessions: Set<string>; pendingAssistantHeaderSessions: Set<string>;
pendingUserMessageMetaBySession: Map<string, { mode?: string; providerID?: string; modelID?: string }>; pendingUserMessageMetaBySession: Map<string, { mode?: string; providerID?: string; modelID?: string; variant?: string }>;
} }
interface MessageActions { interface MessageActions {
loadMessages: (sessionId: string) => Promise<void>; loadMessages: (sessionId: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise<void>; sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
abortCurrentOperation: (currentSessionId?: string) => Promise<void>; abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; _addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void; addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
@@ -546,7 +547,7 @@ export const useMessageStore = create<MessageStore>()(
}); });
}, },
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => { sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => {
if (!currentSessionId) { if (!currentSessionId) {
throw new Error("No session selected"); throw new Error("No session selected");
} }
@@ -663,6 +664,7 @@ export const useMessageStore = create<MessageStore>()(
mode: typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined, mode: typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined,
providerID, providerID,
modelID, modelID,
variant: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined,
}); });
return { pendingAssistantHeaderSessions: next, pendingUserMessageMetaBySession: nextUserMeta }; return { pendingAssistantHeaderSessions: next, pendingUserMessageMetaBySession: nextUserMeta };
}); });
@@ -684,6 +686,7 @@ export const useMessageStore = create<MessageStore>()(
modelID, modelID,
text: effectiveContent, text: effectiveContent,
agent, agent,
variant,
files: filePayloads.length > 0 ? filePayloads : undefined, files: filePayloads.length > 0 ? filePayloads : undefined,
additionalParts: additionalPartsPayload, additionalParts: additionalPartsPayload,
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined, agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
+5 -1
View File
@@ -131,7 +131,7 @@ export interface SessionStore {
unshareSession: (id: string) => Promise<Session | null>; unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void; setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string) => Promise<void>; loadMessages: (sessionId: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => Promise<void>; sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => Promise<void>;
abortCurrentOperation: () => Promise<void>; abortCurrentOperation: () => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void; acknowledgeSessionAbort: (sessionId: string) => void;
armAbortPrompt: (durationMs?: number) => number | null; armAbortPrompt: (durationMs?: number) => number | null;
@@ -172,8 +172,12 @@ export interface SessionStore {
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void; saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null; getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void;
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined;
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>; analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => Promise<Map<string, { providerId: string; modelId: string; timestamp: number }>>;
isOpenChamberCreatedSession: (sessionId: string) => boolean; isOpenChamberCreatedSession: (sessionId: string) => boolean;
markSessionAsOpenChamberCreated: (sessionId: string) => void; markSessionAsOpenChamberCreated: (sessionId: string) => void;
+45
View File
@@ -345,6 +345,7 @@ interface ConfigStore {
agents: Agent[]; agents: Agent[];
currentProviderId: string; currentProviderId: string;
currentModelId: string; currentModelId: string;
currentVariant: string | undefined;
currentAgentName: string | undefined; currentAgentName: string | undefined;
selectedProviderId: string; selectedProviderId: string;
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
@@ -363,6 +364,9 @@ interface ConfigStore {
loadAgents: (options?: { directory?: string | null }) => Promise<boolean>; loadAgents: (options?: { directory?: string | null }) => Promise<boolean>;
setProvider: (providerId: string) => void; setProvider: (providerId: string) => void;
setModel: (modelId: string) => void; setModel: (modelId: string) => void;
setCurrentVariant: (variant: string | undefined) => void;
cycleCurrentVariant: () => void;
getCurrentModelVariants: () => string[];
setAgent: (agentName: string | undefined) => void; setAgent: (agentName: string | undefined) => void;
setSelectedProvider: (providerId: string) => void; setSelectedProvider: (providerId: string) => void;
setSettingsDefaultModel: (model: string | undefined) => void; setSettingsDefaultModel: (model: string | undefined) => void;
@@ -399,6 +403,7 @@ export const useConfigStore = create<ConfigStore>()(
agents: [], agents: [],
currentProviderId: "", currentProviderId: "",
currentModelId: "", currentModelId: "",
currentVariant: undefined,
currentAgentName: undefined, currentAgentName: undefined,
selectedProviderId: "", selectedProviderId: "",
agentModelSelections: {}, agentModelSelections: {},
@@ -628,6 +633,46 @@ export const useConfigStore = create<ConfigStore>()(
}); });
}, },
setCurrentVariant: (variant: string | undefined) => {
set((state) => {
if (state.currentVariant === variant) {
return state;
}
return { currentVariant: variant };
});
},
getCurrentModelVariants: () => {
const model = get().getCurrentModel();
const variants = (model as { variants?: Record<string, unknown> } | undefined)?.variants;
if (!variants) {
return [];
}
return Object.keys(variants);
},
cycleCurrentVariant: () => {
const variantKeys = get().getCurrentModelVariants();
if (variantKeys.length === 0) {
return;
}
const current = get().currentVariant;
if (!current) {
set((state) => (state.currentVariant === variantKeys[0] ? state : { currentVariant: variantKeys[0] }));
return;
}
const index = variantKeys.indexOf(current);
if (index === -1 || index === variantKeys.length - 1) {
set((state) => (state.currentVariant === undefined ? state : { currentVariant: undefined }));
return;
}
const nextVariant = variantKeys[index + 1];
set((state) => (state.currentVariant === nextVariant ? state : { currentVariant: nextVariant }));
},
setSelectedProvider: (providerId: string) => { setSelectedProvider: (providerId: string) => {
set((state) => { set((state) => {
const directoryKey = state.activeDirectoryKey; const directoryKey = state.activeDirectoryKey;
+33 -11
View File
@@ -290,7 +290,7 @@ export const useSessionStore = create<SessionStore>()(
get().evictLeastRecentlyUsed(); get().evictLeastRecentlyUsed();
}, },
loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId), loadMessages: (sessionId: string) => useMessageStore.getState().loadMessages(sessionId),
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>) => { sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[] }>, variant?: string) => {
const draft = get().newSessionDraft; const draft = get().newSessionDraft;
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined; const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
@@ -340,15 +340,25 @@ export const useSessionStore = create<SessionStore>()(
// ignored // ignored
} }
if (draftProviderId && draftModelId) { if (draftProviderId && draftModelId) {
try { try {
useContextStore useContextStore
.getState() .getState()
.saveAgentModelForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId); .saveAgentModelForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId);
} catch { } catch {
// ignored // ignored
}
if (variant !== undefined) {
try {
useContextStore
.getState()
.saveAgentModelVariantForSession(created.id, effectiveDraftAgent, draftProviderId, draftModelId, variant);
} catch {
// ignored
}
}
} }
}
} }
try { try {
@@ -365,7 +375,7 @@ export const useSessionStore = create<SessionStore>()(
try { try {
return await useMessageStore return await useMessageStore
.getState() .getState()
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts); .sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts, variant);
} catch (error) { } catch (error) {
setIdlePhase(created.id); setIdlePhase(created.id);
throw error; throw error;
@@ -385,6 +395,16 @@ export const useSessionStore = create<SessionStore>()(
} catch { } catch {
// ignored // ignored
} }
if (variant !== undefined) {
try {
useContextStore
.getState()
.saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant);
} catch {
// ignored
}
}
} }
if (currentSessionId) { if (currentSessionId) {
@@ -392,7 +412,7 @@ export const useSessionStore = create<SessionStore>()(
} }
try { try {
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts); return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant);
} catch (error) { } catch (error) {
if (currentSessionId) { if (currentSessionId) {
setIdlePhase(currentSessionId); setIdlePhase(currentSessionId);
@@ -473,6 +493,8 @@ export const useSessionStore = create<SessionStore>()(
getSessionAgentSelection: (sessionId: string) => useContextStore.getState().getSessionAgentSelection(sessionId), getSessionAgentSelection: (sessionId: string) => useContextStore.getState().getSessionAgentSelection(sessionId),
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerId, modelId), saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerId, modelId),
getAgentModelForSession: (sessionId: string, agentName: string) => useContextStore.getState().getAgentModelForSession(sessionId, agentName), getAgentModelForSession: (sessionId: string, agentName: string) => useContextStore.getState().getAgentModelForSession(sessionId, agentName),
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, variant),
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => useContextStore.getState().getAgentModelVariantForSession(sessionId, agentName, providerId, modelId),
analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Record<string, unknown>[]) => { analyzeAndSaveExternalSessionChoices: (sessionId: string, agents: Record<string, unknown>[]) => {
const messages = useMessageStore.getState().messages; const messages = useMessageStore.getState().messages;
return useContextStore.getState().analyzeAndSaveExternalSessionChoices(sessionId, agents, messages); return useContextStore.getState().analyzeAndSaveExternalSessionChoices(sessionId, agents, messages);