- IChatSession/IChatTurn interfaces: add optional numCtx field - Mongoose schemas: add numCtx field to ChatSession and ChatTurn - API controller: validate numCtx (>=16384, multiple of 8192) - ChatSessionService: persist numCtx on session, pass through to turn - SessionPanel: add range slider for Context Window (hidden for OpenAI) - Drone buildDroneModelConfig: prefer turn.numCtx over model defaults
299 lines
13 KiB
TypeScript
299 lines
13 KiB
TypeScript
import { ChatSessionMode, type AiProvider, type ChatSession, type AiModel } from '../lib/api';
|
|
import { WorkspaceMode } from '../lib/types';
|
|
import WorkspaceModeIndicator from './WorkspaceModeIndicator';
|
|
|
|
interface SessionPanelProps {
|
|
session: ChatSession | null;
|
|
workspaceMode: WorkspaceMode;
|
|
sessionLocked: boolean;
|
|
providers: AiProvider[];
|
|
selectedProviderId: string;
|
|
selectedModelId: string;
|
|
sessionReasoningEffort: string;
|
|
sessionNumCtx: number;
|
|
isEditingProvider: boolean;
|
|
editProviderId: string;
|
|
editModelId: string;
|
|
isUpdatingProvider: boolean;
|
|
isEditingName: boolean;
|
|
editName: string;
|
|
isUpdatingName: boolean;
|
|
isUpdatingMode: boolean;
|
|
isUpdatingReasoning: boolean;
|
|
isUpdatingNumCtx: boolean;
|
|
onWorkspaceModeChange: (mode: WorkspaceMode) => void;
|
|
onModeChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
|
onReasoningChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
|
onNumCtxChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
onNumCtxCommit: (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => void;
|
|
onStartEditProvider: () => void;
|
|
onSaveProvider: () => void;
|
|
onCancelEditProvider: () => void;
|
|
onEditProviderIdChange: (id: string) => void;
|
|
onEditModelIdChange: (id: string) => void;
|
|
onStartEditName: () => void;
|
|
onSaveName: () => void;
|
|
onCancelEditName: () => void;
|
|
onEditNameChange: (name: string) => void;
|
|
getProviderName: (id: string) => string;
|
|
getModelName: (providerId: string, modelId: string) => string;
|
|
getSortedModels: (providerId: string) => AiProvider['models'];
|
|
truncateModelName: (name: string, maxLength?: number) => string;
|
|
getSelectedModelCapabilities: () => { hasThinking: boolean } | null;
|
|
getSelectedModel: () => AiModel | null;
|
|
}
|
|
|
|
export default function SessionPanel({
|
|
session,
|
|
workspaceMode,
|
|
sessionLocked,
|
|
providers,
|
|
selectedProviderId,
|
|
selectedModelId,
|
|
sessionReasoningEffort,
|
|
sessionNumCtx,
|
|
isEditingProvider,
|
|
editProviderId,
|
|
editModelId,
|
|
isUpdatingProvider,
|
|
isEditingName,
|
|
editName,
|
|
isUpdatingName,
|
|
isUpdatingMode,
|
|
isUpdatingReasoning,
|
|
isUpdatingNumCtx,
|
|
onWorkspaceModeChange,
|
|
onModeChange,
|
|
onReasoningChange,
|
|
onNumCtxChange,
|
|
onNumCtxCommit,
|
|
onStartEditProvider,
|
|
onSaveProvider,
|
|
onCancelEditProvider,
|
|
onEditProviderIdChange,
|
|
onEditModelIdChange,
|
|
onStartEditName,
|
|
onSaveName,
|
|
onCancelEditName,
|
|
onEditNameChange,
|
|
getProviderName,
|
|
getModelName,
|
|
getSortedModels,
|
|
truncateModelName,
|
|
getSelectedModelCapabilities,
|
|
getSelectedModel,
|
|
}: SessionPanelProps) {
|
|
return (
|
|
<div className="border-b border-border-subtle shrink-0">
|
|
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
|
|
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
|
Session
|
|
</h3>
|
|
<WorkspaceModeIndicator
|
|
mode={workspaceMode}
|
|
onChange={onWorkspaceModeChange}
|
|
disabled={!sessionLocked || isEditingProvider}
|
|
/>
|
|
</div>
|
|
<div className="p-4 space-y-3">
|
|
<div>
|
|
<div className="text-xs text-text-muted">Name</div>
|
|
{isEditingName ? (
|
|
<div className="flex gap-2 mt-1">
|
|
<input
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => onEditNameChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); onSaveName(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); onCancelEditName(); }
|
|
}}
|
|
className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
title="Save"
|
|
onClick={onSaveName}
|
|
disabled={!editName.trim() || isUpdatingName}
|
|
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
title="Cancel"
|
|
onClick={onCancelEditName}
|
|
disabled={isUpdatingName}
|
|
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2 items-center mt-1">
|
|
<div className="text-text-primary flex-1">{session?.name}</div>
|
|
<button
|
|
title="Edit name"
|
|
onClick={onStartEditName}
|
|
className="text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isEditingProvider ? (
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Provider</div>
|
|
<select
|
|
value={editProviderId || ''}
|
|
onChange={(e) => { onEditProviderIdChange(e.target.value); onEditModelIdChange(''); }}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
|
|
>
|
|
<option value="">-- Select Provider --</option>
|
|
{providers.map((provider) => (
|
|
<option key={provider._id} value={provider._id}>
|
|
{provider.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Model</div>
|
|
<select
|
|
value={editModelId || ''}
|
|
onChange={(e) => onEditModelIdChange(e.target.value)}
|
|
disabled={!editProviderId}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
|
|
>
|
|
{!editProviderId ? (
|
|
<option value="">Select provider first</option>
|
|
) : (
|
|
<>
|
|
<option value="">-- Select Model --</option>
|
|
{getSortedModels(editProviderId).map((model) => (
|
|
<option key={model.id} value={model.id}>
|
|
{truncateModelName(model.name)}
|
|
</option>
|
|
))}
|
|
</>
|
|
)}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end gap-1 pb-1">
|
|
<button
|
|
title="Save"
|
|
onClick={onSaveProvider}
|
|
disabled={!editProviderId || !editModelId || isUpdatingProvider}
|
|
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
title="Cancel"
|
|
onClick={onCancelEditProvider}
|
|
disabled={isUpdatingProvider}
|
|
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Provider</div>
|
|
<div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-xs text-text-muted">Model</div>
|
|
<div
|
|
className="text-text-primary text-sm mt-1 truncate"
|
|
title={getModelName(selectedProviderId, selectedModelId)}
|
|
>
|
|
{getModelName(selectedProviderId, selectedModelId)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
title="Change provider/model..."
|
|
onClick={onStartEditProvider}
|
|
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="text-xs text-text-muted">Mode</div>
|
|
<select
|
|
value={session?.mode || ChatSessionMode.Build}
|
|
onChange={onModeChange}
|
|
disabled={isUpdatingMode}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{Object.values(ChatSessionMode).map((mode) => (
|
|
<option key={mode} value={mode}>
|
|
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-text-muted">Reasoning</div>
|
|
<select
|
|
value={sessionReasoningEffort}
|
|
onChange={onReasoningChange}
|
|
disabled={isUpdatingReasoning || !getSelectedModelCapabilities()?.hasThinking}
|
|
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title={
|
|
!getSelectedModelCapabilities()?.hasThinking
|
|
? "Selected model does not support reasoning"
|
|
: "Controls how much the model thinks before responding"
|
|
}
|
|
>
|
|
<option value="off">Off</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</select>
|
|
</div>
|
|
{providers.find(p => p._id === selectedProviderId)?.apiType !== 'openai' && (
|
|
<div>
|
|
<div className="text-xs text-text-muted">Context Window</div>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<input
|
|
type="range"
|
|
min={16384}
|
|
max={getSelectedModel()?.contextWindow || 131072}
|
|
step={8192}
|
|
value={sessionNumCtx}
|
|
onChange={onNumCtxChange}
|
|
onMouseUp={onNumCtxCommit}
|
|
onTouchEnd={onNumCtxCommit}
|
|
className="flex-1 h-2 bg-bg-tertiary rounded appearance-none cursor-pointer accent-brand"
|
|
/>
|
|
<span className="text-xs text-text-secondary w-16 text-right tabular-nums">
|
|
{sessionNumCtx.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|