add numCtx (Context Window) setting to SESSION panel

- 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
This commit is contained in:
Rob Colbert 2026-05-15 16:02:22 -04:00
parent 8650533697
commit 140c8a75ee
10 changed files with 105 additions and 7 deletions

View File

@ -1,4 +1,4 @@
import { ChatSessionMode, type AiProvider, type ChatSession } from '../lib/api'; import { ChatSessionMode, type AiProvider, type ChatSession, type AiModel } from '../lib/api';
import { WorkspaceMode } from '../lib/types'; import { WorkspaceMode } from '../lib/types';
import WorkspaceModeIndicator from './WorkspaceModeIndicator'; import WorkspaceModeIndicator from './WorkspaceModeIndicator';
@ -10,6 +10,7 @@ interface SessionPanelProps {
selectedProviderId: string; selectedProviderId: string;
selectedModelId: string; selectedModelId: string;
sessionReasoningEffort: string; sessionReasoningEffort: string;
sessionNumCtx: number;
isEditingProvider: boolean; isEditingProvider: boolean;
editProviderId: string; editProviderId: string;
editModelId: string; editModelId: string;
@ -19,9 +20,12 @@ interface SessionPanelProps {
isUpdatingName: boolean; isUpdatingName: boolean;
isUpdatingMode: boolean; isUpdatingMode: boolean;
isUpdatingReasoning: boolean; isUpdatingReasoning: boolean;
isUpdatingNumCtx: boolean;
onWorkspaceModeChange: (mode: WorkspaceMode) => void; onWorkspaceModeChange: (mode: WorkspaceMode) => void;
onModeChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; onModeChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onReasoningChange: (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; onStartEditProvider: () => void;
onSaveProvider: () => void; onSaveProvider: () => void;
onCancelEditProvider: () => void; onCancelEditProvider: () => void;
@ -36,6 +40,7 @@ interface SessionPanelProps {
getSortedModels: (providerId: string) => AiProvider['models']; getSortedModels: (providerId: string) => AiProvider['models'];
truncateModelName: (name: string, maxLength?: number) => string; truncateModelName: (name: string, maxLength?: number) => string;
getSelectedModelCapabilities: () => { hasThinking: boolean } | null; getSelectedModelCapabilities: () => { hasThinking: boolean } | null;
getSelectedModel: () => AiModel | null;
} }
export default function SessionPanel({ export default function SessionPanel({
@ -46,6 +51,7 @@ export default function SessionPanel({
selectedProviderId, selectedProviderId,
selectedModelId, selectedModelId,
sessionReasoningEffort, sessionReasoningEffort,
sessionNumCtx,
isEditingProvider, isEditingProvider,
editProviderId, editProviderId,
editModelId, editModelId,
@ -55,9 +61,12 @@ export default function SessionPanel({
isUpdatingName, isUpdatingName,
isUpdatingMode, isUpdatingMode,
isUpdatingReasoning, isUpdatingReasoning,
isUpdatingNumCtx,
onWorkspaceModeChange, onWorkspaceModeChange,
onModeChange, onModeChange,
onReasoningChange, onReasoningChange,
onNumCtxChange,
onNumCtxCommit,
onStartEditProvider, onStartEditProvider,
onSaveProvider, onSaveProvider,
onCancelEditProvider, onCancelEditProvider,
@ -72,6 +81,7 @@ export default function SessionPanel({
getSortedModels, getSortedModels,
truncateModelName, truncateModelName,
getSelectedModelCapabilities, getSelectedModelCapabilities,
getSelectedModel,
}: SessionPanelProps) { }: SessionPanelProps) {
return ( return (
<div className="border-b border-border-subtle shrink-0"> <div className="border-b border-border-subtle shrink-0">
@ -261,6 +271,27 @@ export default function SessionPanel({
<option value="high">High</option> <option value="high">High</option>
</select> </select>
</div> </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>
</div> </div>
); );

View File

@ -358,6 +358,7 @@ export interface ChatSession {
provider: string | AiProvider; provider: string | AiProvider;
selectedModel: string; selectedModel: string;
reasoningEffort?: string; reasoningEffort?: string;
numCtx?: number;
stats: ChatSessionStats; stats: ChatSessionStats;
pins: Array<{ _id?: string; content: string }>; pins: Array<{ _id?: string; content: string }>;
} }
@ -421,6 +422,7 @@ export interface ChatTurn {
session: string | ChatSession; session: string | ChatSession;
provider: string | AiProvider; provider: string | AiProvider;
llm: string; llm: string;
numCtx?: number;
mode: ChatSessionMode; mode: ChatSessionMode;
status: "processing" | "finished" | "aborted" | "error"; status: "processing" | "finished" | "aborted" | "error";
prompts: ChatTurnPrompts; prompts: ChatTurnPrompts;

View File

@ -68,6 +68,8 @@ export default function ChatSessionView() {
const [selectedProviderId, setSelectedProviderId] = useState<string>(''); const [selectedProviderId, setSelectedProviderId] = useState<string>('');
const [selectedModelId, setSelectedModelId] = useState<string>(''); const [selectedModelId, setSelectedModelId] = useState<string>('');
const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off'); const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off');
const [sessionNumCtx, setSessionNumCtx] = useState<number>(131072);
const [isUpdatingNumCtx, setIsUpdatingNumCtx] = useState(false);
const [isEditingProvider, setIsEditingProvider] = useState(false); const [isEditingProvider, setIsEditingProvider] = useState(false);
const [editProviderId, setEditProviderId] = useState(''); const [editProviderId, setEditProviderId] = useState('');
const [editModelId, setEditModelId] = useState(''); const [editModelId, setEditModelId] = useState('');
@ -214,6 +216,15 @@ export default function ChatSessionView() {
setSelectedProviderId(providerId || ''); setSelectedProviderId(providerId || '');
setSelectedModelId(sessionData.selectedModel || ''); setSelectedModelId(sessionData.selectedModel || '');
setSessionReasoningEffort(sessionData.reasoningEffort || 'off'); setSessionReasoningEffort(sessionData.reasoningEffort || 'off');
// Initialize numCtx: persisted value wins, otherwise model's contextWindow
if (sessionData.numCtx) {
setSessionNumCtx(sessionData.numCtx);
} else {
const provider = allProviders.find(p => p._id === providerId);
const model = provider?.models.find(m => m.id === sessionData.selectedModel);
setSessionNumCtx(model?.contextWindow || 131072);
}
// Set connection state to connecting - will update on socket connect // Set connection state to connecting - will update on socket connect
setConnectionState('connecting'); setConnectionState('connecting');
@ -793,13 +804,16 @@ export default function ChatSessionView() {
} }
}; };
const getSelectedModelCapabilities = useCallback(() => { const getSelectedModel = useCallback(() => {
const provider = providers.find(p => p._id === selectedProviderId); const provider = providers.find(p => p._id === selectedProviderId);
if (!provider) return null; if (!provider) return null;
const model = provider.models.find(m => m.id === selectedModelId); return provider.models.find(m => m.id === selectedModelId) || null;
return model?.capabilities || null;
}, [providers, selectedProviderId, selectedModelId]); }, [providers, selectedProviderId, selectedModelId]);
const getSelectedModelCapabilities = useCallback(() => {
return getSelectedModel()?.capabilities || null;
}, [getSelectedModel]);
const handleReasoningChange = async (e: React.ChangeEvent<HTMLSelectElement>) => { const handleReasoningChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
if (!session) return; if (!session) return;
@ -821,6 +835,28 @@ export default function ChatSessionView() {
} }
}; };
const handleNumCtxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSessionNumCtx(parseInt(e.target.value, 10));
};
const handleNumCtxCommit = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
if (!session || isUpdatingNumCtx) return;
const newValue = parseInt(e.currentTarget.value, 10);
setIsUpdatingNumCtx(true);
try {
const updatedSession = await chatSessionApi.update(session._id, {
numCtx: newValue
});
setSession(updatedSession);
showToast(`Context window set to ${newValue.toLocaleString()}`);
} catch (err) {
showToast(`Failed to change context window: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsUpdatingNumCtx(false);
}
};
const getProviderName = (id: string) => { const getProviderName = (id: string) => {
return providers.find(p => p._id === id)?.name ?? 'None'; return providers.find(p => p._id === id)?.name ?? 'None';
}; };
@ -859,6 +895,13 @@ export default function ChatSessionView() {
setSession(updatedSession); setSession(updatedSession);
setSelectedProviderId(editProviderId); setSelectedProviderId(editProviderId);
setSelectedModelId(editModelId); setSelectedModelId(editModelId);
// Reset numCtx to new model's max context window
const newProvider = providers.find(p => p._id === editProviderId);
const newModel = newProvider?.models.find(m => m.id === editModelId);
const newMaxCtx = newModel?.contextWindow || 131072;
setSessionNumCtx(newMaxCtx);
setIsEditingProvider(false); setIsEditingProvider(false);
showToast('Provider and model updated'); showToast('Provider and model updated');
} catch (err) { } catch (err) {
@ -1173,6 +1216,7 @@ export default function ChatSessionView() {
selectedProviderId={selectedProviderId} selectedProviderId={selectedProviderId}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
sessionReasoningEffort={sessionReasoningEffort} sessionReasoningEffort={sessionReasoningEffort}
sessionNumCtx={sessionNumCtx}
isEditingProvider={isEditingProvider} isEditingProvider={isEditingProvider}
editProviderId={editProviderId} editProviderId={editProviderId}
editModelId={editModelId} editModelId={editModelId}
@ -1182,9 +1226,12 @@ export default function ChatSessionView() {
isUpdatingName={isUpdatingName} isUpdatingName={isUpdatingName}
isUpdatingMode={isUpdatingMode} isUpdatingMode={isUpdatingMode}
isUpdatingReasoning={isUpdatingReasoning} isUpdatingReasoning={isUpdatingReasoning}
isUpdatingNumCtx={isUpdatingNumCtx}
onWorkspaceModeChange={handleWorkspaceModeChange} onWorkspaceModeChange={handleWorkspaceModeChange}
onModeChange={handleModeChange} onModeChange={handleModeChange}
onReasoningChange={handleReasoningChange} onReasoningChange={handleReasoningChange}
onNumCtxChange={handleNumCtxChange}
onNumCtxCommit={handleNumCtxCommit}
onStartEditProvider={handleStartEditProvider} onStartEditProvider={handleStartEditProvider}
onSaveProvider={handleSaveProvider} onSaveProvider={handleSaveProvider}
onCancelEditProvider={handleCancelEditProvider} onCancelEditProvider={handleCancelEditProvider}
@ -1199,6 +1246,7 @@ export default function ChatSessionView() {
getSortedModels={getSortedModels} getSortedModels={getSortedModels}
truncateModelName={truncateModelName} truncateModelName={truncateModelName}
getSelectedModelCapabilities={getSelectedModelCapabilities} getSelectedModelCapabilities={getSelectedModelCapabilities}
getSelectedModel={getSelectedModel}
/> />
<ProjectPanel <ProjectPanel

View File

@ -179,6 +179,7 @@ class ChatSessionController extends DtpController {
selectedModel: string; selectedModel: string;
mode: ChatSessionMode; mode: ChatSessionMode;
reasoningEffort: "off" | "low" | "medium" | "high"; reasoningEffort: "off" | "low" | "medium" | "high";
numCtx: number;
}> = {}; }> = {};
if (updates.name !== undefined) { if (updates.name !== undefined) {
@ -204,6 +205,12 @@ class ChatSessionController extends DtpController {
} }
allowedUpdates.reasoningEffort = updates.reasoningEffort; allowedUpdates.reasoningEffort = updates.reasoningEffort;
} }
if (updates.numCtx !== undefined) {
if (typeof updates.numCtx !== "number" || !Number.isInteger(updates.numCtx) || updates.numCtx < 16384 || updates.numCtx % 8192 !== 0) {
throw new Error(`Invalid numCtx: ${updates.numCtx}. Must be an integer >= 16384 and a multiple of 8192.`);
}
allowedUpdates.numCtx = updates.numCtx;
}
const session = await ChatSessionService.update( const session = await ChatSessionService.update(
res.locals.chatSession._id, res.locals.chatSession._id,

View File

@ -30,6 +30,7 @@ export const ChatSessionSchema = new Schema<IChatSession>({
enum: ["off", "low", "medium", "high"], enum: ["off", "low", "medium", "high"],
default: "off", default: "off",
}, },
numCtx: { type: Number },
stats: { stats: {
turnCount: { type: Number, default: 0, required: true }, turnCount: { type: Number, default: 0, required: true },
toolCallCount: { type: Number, default: 0, required: true }, toolCallCount: { type: Number, default: 0, required: true },

View File

@ -69,6 +69,7 @@ export const ChatTurnSchema = new Schema<IChatTurn>({
enum: ["off", "low", "medium", "high"], enum: ["off", "low", "medium", "high"],
default: "off", default: "off",
}, },
numCtx: { type: Number },
mode: { mode: {
type: String, type: String,
enum: ChatSessionMode, enum: ChatSessionMode,

View File

@ -202,6 +202,7 @@ class ChatSessionService extends DtpService {
selectedModel: string; selectedModel: string;
mode: ChatSessionMode; mode: ChatSessionMode;
reasoningEffort: ReasoningEffort; reasoningEffort: ReasoningEffort;
numCtx: number;
}>, }>,
): Promise<IChatSession> { ): Promise<IChatSession> {
const session = await ChatSession.findById(chatSessionId); const session = await ChatSession.findById(chatSessionId);
@ -230,6 +231,9 @@ class ChatSessionService extends DtpService {
if (updates.reasoningEffort !== undefined) { if (updates.reasoningEffort !== undefined) {
session.reasoningEffort = updates.reasoningEffort; session.reasoningEffort = updates.reasoningEffort;
} }
if (updates.numCtx !== undefined) {
session.numCtx = updates.numCtx;
}
await session.save(); await session.save();
@ -276,6 +280,7 @@ class ChatSessionService extends DtpService {
provider: session.provider, provider: session.provider,
llm: session.selectedModel, llm: session.selectedModel,
reasoningEffort: (session as IChatSession).reasoningEffort || "off", reasoningEffort: (session as IChatSession).reasoningEffort || "off",
numCtx: (session as IChatSession).numCtx,
mode: session.mode, mode: session.mode,
status: ChatTurnStatus.Processing, status: ChatTurnStatus.Processing,
prompts: { prompts: {

View File

@ -218,7 +218,7 @@ class AgentService extends GadgetService {
try { try {
response = await AiService.chat( response = await AiService.chat(
turn.provider, turn.provider,
this.buildDroneModelConfig(turn.provider, turn.llm, currentReasoning), this.buildDroneModelConfig(turn.provider, turn.llm, currentReasoning, turn.numCtx),
chatOptions, chatOptions,
this.makeStreamHandler(socket), this.makeStreamHandler(socket),
); );
@ -474,6 +474,7 @@ class AgentService extends GadgetService {
provider: IAiProvider | GadgetId, provider: IAiProvider | GadgetId,
modelId: string, modelId: string,
reasoning: boolean | "low" | "medium" | "high", reasoning: boolean | "low" | "medium" | "high",
numCtxOverride?: number,
): Omit<IDroneModelConfig, "provider"> { ): Omit<IDroneModelConfig, "provider"> {
const settings = this.getModelSettings(provider, modelId); const settings = this.getModelSettings(provider, modelId);
return { return {
@ -484,7 +485,7 @@ class AgentService extends GadgetService {
topP: settings?.topP ?? 0.9, topP: settings?.topP ?? 0.9,
topK: settings?.topK ?? 40, topK: settings?.topK ?? 40,
numPredict: settings?.numPredict ?? -1, // -1 = unlimited (Ollama) numPredict: settings?.numPredict ?? -1, // -1 = unlimited (Ollama)
numCtx: settings?.numCtx ?? 131072, // 128k fallback numCtx: numCtxOverride ?? settings?.numCtx ?? 131072, // session override > model settings > 128k fallback
maxCompletionTokens: settings?.maxCompletionTokens ?? 16384, // 16k fallback maxCompletionTokens: settings?.maxCompletionTokens ?? 16384, // 16k fallback
}, },
}; };
@ -626,7 +627,7 @@ class AgentService extends GadgetService {
const response = await AiService.chat( const response = await AiService.chat(
provider, provider,
this.buildDroneModelConfig(provider, turn.llm, false), this.buildDroneModelConfig(provider, turn.llm, false, turn.numCtx),
chatOptions, chatOptions,
streamHandler, streamHandler,
); );

View File

@ -35,6 +35,7 @@ export interface IChatSession {
provider: IAiProvider | GadgetId; provider: IAiProvider | GadgetId;
selectedModel: string; selectedModel: string;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
numCtx?: number;
stats: { stats: {
turnCount: number; turnCount: number;
toolCallCount: number; toolCallCount: number;

View File

@ -86,6 +86,7 @@ export interface IChatTurn {
provider: IAiProvider | GadgetId; provider: IAiProvider | GadgetId;
llm: string; // id/name of the model used to process the prompt llm: string; // id/name of the model used to process the prompt
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
numCtx?: number;
mode: ChatSessionMode; mode: ChatSessionMode;
status: ChatTurnStatus; status: ChatTurnStatus;
prompts: IChatTurnPrompts; prompts: IChatTurnPrompts;