feat(webui): add context window setting

This commit is contained in:
Xubin Ren
2026-05-29 13:09:08 +08:00
parent 3a420136bb
commit 404b68cdd4
17 changed files with 280 additions and 64 deletions
+89 -42
View File
@@ -139,6 +139,7 @@ interface AgentSettingsDraft {
provider: string;
modelPreset: string;
presetLabel: string;
contextWindowTokens: number;
timezone: string;
botName: string;
botIcon: string;
@@ -164,6 +165,7 @@ type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const FALLBACK_TIMEZONES = [
"UTC",
@@ -279,6 +281,10 @@ function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets
return payload.model_presets.find((preset) => preset.is_default) ?? null;
}
function normalizeContextWindowTokens(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 65_536;
}
function editableDefaultProvider(payload: SettingsPayload): string {
const base = defaultPreset(payload);
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
@@ -373,6 +379,7 @@ export function SettingsView({
provider: "",
modelPreset: "default",
presetLabel: "Default",
contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
@@ -398,6 +405,9 @@ export function SettingsView({
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
contextWindowTokens: normalizeContextWindowTokens(
activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
@@ -533,6 +543,7 @@ export function SettingsView({
form.modelPreset !== activePresetName ||
form.model !== selectedPreset.model ||
form.provider !== selectedProvider ||
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
(!selectedPreset.is_default && form.presetLabel.trim() !== selectedPreset.label)
);
}, [form, settings]);
@@ -644,14 +655,23 @@ export function SettingsView({
label: form.presetLabel.trim(),
model: form.model,
provider: form.provider,
...(form.contextWindowTokens !== selectedPreset.context_window_tokens
? { contextWindowTokens: form.contextWindowTokens }
: {}),
});
} else {
const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model;
const defaultProvider = editableDefaultProvider(settings);
const defaultContextWindowTokens = normalizeContextWindowTokens(
defaultPreset(settings)?.context_window_tokens ?? settings.agent.context_window_tokens,
);
payload = await updateSettings(token, {
modelPreset: form.modelPreset,
...(form.model !== defaultModel ? { model: form.model } : {}),
...(form.provider !== defaultProvider ? { provider: form.provider } : {}),
...(form.contextWindowTokens !== defaultContextWindowTokens
? { contextWindowTokens: form.contextWindowTokens }
: {}),
});
}
applyPayload(payload);
@@ -1876,6 +1896,9 @@ function ModelsSettings({
? editableDefaultProvider(settings)
: nextPreset?.provider ?? prev.provider,
presetLabel: nextPreset?.label ?? modelPreset,
contextWindowTokens: normalizeContextWindowTokens(
nextPreset?.context_window_tokens ?? prev.contextWindowTokens,
),
}));
}}
onCreateConfiguration={onCreateConfiguration}
@@ -1895,49 +1918,73 @@ function ModelsSettings({
/>
</SettingsRow>
) : null}
<SettingsRow
title={t("settings.rows.provider")}
description={t("settings.help.provider")}
<SettingsRow
title={t("settings.rows.provider")}
description={t("settings.help.provider")}
>
<ProviderPicker
providers={providerOptions}
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos}
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
/>
</SettingsRow>
{selectedProviderNeedsSignIn ? (
<SettingsRow
title={tx("settings.oauth.signInRequired", "Sign in required")}
description={tx(
"settings.oauth.signInBeforeSaving",
"Sign in before saving this OAuth provider as the active model provider.",
)}
>
<Button
size="sm"
variant="outline"
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
className="rounded-full"
>
<ProviderPicker
providers={providerOptions}
value={providerValue}
emptyLabel={t("settings.byok.noConfiguredProviders")}
showProviderLogos={showBrandLogos}
onChange={(provider) => setForm((prev) => ({ ...prev, provider }))}
/>
</SettingsRow>
{selectedProviderNeedsSignIn ? (
<SettingsRow
title={tx("settings.oauth.signInRequired", "Sign in required")}
description={tx("settings.oauth.signInBeforeSaving", "Sign in before saving this OAuth provider as the active model provider.")}
>
<Button
size="sm"
variant="outline"
onClick={() => selectedProvider && onProviderOAuthLogin(selectedProvider.name)}
disabled={!selectedProvider?.oauth_login_supported || selectedProviderSigningIn}
className="rounded-full"
>
{selectedProviderSigningIn ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{selectedProviderSigningIn
? tx("settings.oauth.signingIn", "Signing in...")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
</SettingsRow>
) : null}
<SettingsRow
title={t("settings.rows.model")}
description={t("settings.help.model")}
>
<Input
value={form.model}
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
{selectedProviderSigningIn ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{selectedProviderSigningIn
? tx("settings.oauth.signingIn", "Signing in...")
: tx("settings.oauth.signIn", "Sign in")}
</Button>
</SettingsRow>
) : null}
<SettingsRow
title={t("settings.rows.model")}
description={t("settings.help.model")}
>
<Input
value={form.model}
onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.contextWindow", "Context window")}
description={tx(
"settings.help.contextWindow",
"Choose the default context budget for this model configuration.",
)}
>
<SegmentedControl
value={String(form.contextWindowTokens)}
options={CONTEXT_WINDOW_TOKEN_OPTIONS.map((tokens) => ({
value: String(tokens),
label: tokens === 262_144 ? "256K" : "64K",
}))}
onChange={(value) =>
setForm((prev) => ({
...prev,
contextWindowTokens: normalizeContextWindowTokens(Number(value)),
}))
}
/>
</SettingsRow>
<SettingsFooter
dirty={dirty}
saving={saving}
+4 -2
View File
@@ -146,7 +146,8 @@
"cliAppsFilter": "Filter",
"engine": "Engine",
"logs": "Logs",
"diagnostics": "Diagnostics"
"diagnostics": "Diagnostics",
"contextWindow": "Context window"
},
"help": {
"theme": "Switch between light and dark appearance.",
@@ -184,7 +185,8 @@
"logs": "Open the native engine log folder.",
"diagnostics": "Export a small runtime report for support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
},
"timezone": {
"select": "Select timezone",
+4 -2
View File
@@ -137,7 +137,8 @@
"cliAppsFilter": "Filtro de apps CLI",
"engine": "Motor",
"logs": "Registros",
"diagnostics": "Diagnóstico"
"diagnostics": "Diagnóstico",
"contextWindow": "Context window"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
@@ -175,7 +176,8 @@
"logs": "Abre la carpeta de registros del motor de escritorio.",
"diagnostics": "Exporta un pequeño informe de runtime para soporte.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
},
"values": {
"light": "Claro",
+4 -2
View File
@@ -137,7 +137,8 @@
"cliAppsFilter": "Filtre des apps CLI",
"engine": "Moteur",
"logs": "Journaux",
"diagnostics": "Diagnostics"
"diagnostics": "Diagnostics",
"contextWindow": "Context window"
},
"help": {
"theme": "Basculer entre les apparences claire et sombre.",
@@ -175,7 +176,8 @@
"logs": "Ouvrir le dossier des journaux du moteur natif.",
"diagnostics": "Exporter un petit rapport runtime pour le support.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
},
"values": {
"light": "Clair",
+4 -2
View File
@@ -137,7 +137,8 @@
"cliAppsFilter": "Filter aplikasi CLI",
"engine": "Engine",
"logs": "Log",
"diagnostics": "Diagnostik"
"diagnostics": "Diagnostik",
"contextWindow": "Context window"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
@@ -175,7 +176,8 @@
"logs": "Buka folder log native engine.",
"diagnostics": "Ekspor laporan runtime kecil untuk dukungan.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
},
"values": {
"light": "Terang",
+4 -2
View File
@@ -137,7 +137,8 @@
"cliAppsFilter": "CLI アプリフィルター",
"engine": "エンジン",
"logs": "ログ",
"diagnostics": "診断"
"diagnostics": "診断",
"contextWindow": "Context window"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
@@ -175,7 +176,8 @@
"logs": "Appエンジンのログフォルダを開きます。",
"diagnostics": "サポート用の小さなランタイムレポートを書き出します。",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
},
"values": {
"light": "ライト",
+4 -2
View File
@@ -137,7 +137,8 @@
"cliAppsFilter": "CLI 앱 필터",
"engine": "엔진",
"logs": "로그",
"diagnostics": "진단"
"diagnostics": "진단",
"contextWindow": "Context window"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
@@ -175,7 +176,8 @@
"logs": "App 엔진 로그 폴더를 엽니다.",
"diagnostics": "지원용 작은 런타임 보고서를 내보냅니다.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
},
"values": {
"light": "라이트",
+4 -2
View File
@@ -137,7 +137,8 @@
"cliAppsFilter": "Bộ lọc ứng dụng CLI",
"engine": "Engine",
"logs": "Nhật ký",
"diagnostics": "Chẩn đoán"
"diagnostics": "Chẩn đoán",
"contextWindow": "Context window"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
@@ -175,7 +176,8 @@
"logs": "Mở thư mục nhật ký native engine.",
"diagnostics": "Xuất báo cáo runtime nhỏ để hỗ trợ.",
"localServiceAccessNative": "Allow Full Access shell commands to reach services on this Mac.",
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission."
"webuiDefaultAccessNative": "Used by native chats without a project-specific permission.",
"contextWindow": "Choose the default context budget for this model configuration."
},
"values": {
"light": "Sáng",
+4 -2
View File
@@ -146,7 +146,8 @@
"cliAppsFilter": "筛选",
"engine": "引擎",
"logs": "日志",
"diagnostics": "诊断"
"diagnostics": "诊断",
"contextWindow": "上下文窗口"
},
"help": {
"theme": "在浅色和深色外观之间切换。",
@@ -184,7 +185,8 @@
"logs": "打开App引擎日志文件夹。",
"diagnostics": "导出一份用于支持排查的运行时报告。",
"localServiceAccessNative": "允许完全访问模式下的 shell 命令访问这台 Mac 上的服务。",
"webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。"
"webuiDefaultAccessNative": "用于没有单独选择权限的原生 App 对话。",
"contextWindow": "选择这个模型配置默认使用的上下文预算。"
},
"timezone": {
"select": "选择时区",
+4 -2
View File
@@ -137,7 +137,8 @@
"cliAppsFilter": "CLI 應用篩選",
"engine": "引擎",
"logs": "日誌",
"diagnostics": "診斷"
"diagnostics": "診斷",
"contextWindow": "上下文視窗"
},
"help": {
"theme": "在淺色與深色外觀之間切換。",
@@ -175,7 +176,8 @@
"logs": "開啟App引擎日誌資料夾。",
"diagnostics": "匯出一份供支援排查用的執行階段報告。",
"localServiceAccessNative": "允許完全存取模式下的 shell 命令存取這台 Mac 上的服務。",
"webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。"
"webuiDefaultAccessNative": "用於沒有單獨選擇權限的原生 App 對話。",
"contextWindow": "選擇這個模型設定預設使用的上下文預算。"
},
"values": {
"light": "淺色",
+6
View File
@@ -280,6 +280,9 @@ export async function updateSettings(
}
if (update.model !== undefined) query.set("model", update.model);
if (update.provider !== undefined) query.set("provider", update.provider);
if (update.contextWindowTokens !== undefined) {
query.set("context_window_tokens", String(update.contextWindowTokens));
}
if (update.timezone !== undefined) query.set("timezone", update.timezone);
if (update.botName !== undefined) query.set("bot_name", update.botName);
if (update.botIcon !== undefined) query.set("bot_icon", update.botIcon);
@@ -315,6 +318,9 @@ export async function updateModelConfiguration(
if (configuration.label !== undefined) query.set("label", configuration.label);
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
if (configuration.model !== undefined) query.set("model", configuration.model);
if (configuration.contextWindowTokens !== undefined) {
query.set("context_window_tokens", String(configuration.contextWindowTokens));
}
return request<SettingsPayload>(
`${base}/api/settings/model-configurations/update?${query}`,
token,
+2
View File
@@ -517,6 +517,7 @@ export interface SettingsUpdate {
model?: string;
provider?: string;
modelPreset?: string | null;
contextWindowTokens?: number;
timezone?: string;
botName?: string;
botIcon?: string;
@@ -535,6 +536,7 @@ export interface ModelConfigurationUpdate {
label?: string;
provider?: string;
model?: string;
contextWindowTokens?: number;
}
export interface ProviderSettingsUpdate {
+4 -2
View File
@@ -65,6 +65,7 @@ describe("webui API helpers", () => {
modelPreset: "default",
model: "openrouter/test",
provider: "openrouter",
contextWindowTokens: 262144,
timezone: "Asia/Shanghai",
botName: "nanobot",
botIcon: "nb",
@@ -72,7 +73,7 @@ describe("webui API helpers", () => {
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
@@ -100,10 +101,11 @@ describe("webui API helpers", () => {
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
contextWindowTokens: 65536,
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5",
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&context_window_tokens=65536",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
+24 -1
View File
@@ -121,7 +121,7 @@ const installedAnyGen = {
function renderSettingsView(
options: {
initialSection?: "apps" | "advanced";
initialSection?: "apps" | "advanced" | "models";
onSettingsChange?: (payload: SettingsPayload) => void;
} = {},
) {
@@ -222,6 +222,29 @@ describe("SettingsView Apps catalog", () => {
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
it("shows context window options in model settings", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
}),
);
renderSettingsView({ initialSection: "models" });
expect(await screen.findByText("Context window")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
});
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {