feat(agent): make model presets session-scoped (#4866)
This commit is contained in:
@@ -170,8 +170,14 @@ interface ModelBadgeInfo {
|
||||
needsSetup: boolean;
|
||||
}
|
||||
|
||||
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
|
||||
function modelPresetForBadge(
|
||||
settings: SettingsPayload | null,
|
||||
scopedPreset: string | null,
|
||||
): SettingsPayload["model_presets"][number] | null {
|
||||
if (!settings) return null;
|
||||
if (scopedPreset) {
|
||||
return settings.model_presets.find((preset) => preset.name === scopedPreset) ?? null;
|
||||
}
|
||||
const configured = settings.agent.model_preset || "default";
|
||||
return (
|
||||
settings.model_presets.find((preset) => preset.name === configured)
|
||||
@@ -180,19 +186,25 @@ function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["m
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedModelProvider(settings: SettingsPayload | null, modelName: string | null): string | null {
|
||||
const preset = activeModelPreset(settings);
|
||||
const rawProvider = preset?.provider || settings?.agent.provider || null;
|
||||
if (rawProvider === "auto") {
|
||||
return settings?.agent.resolved_provider || inferProviderFromModelName(modelName) || null;
|
||||
}
|
||||
return rawProvider || inferProviderFromModelName(modelName);
|
||||
}
|
||||
|
||||
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
|
||||
const model = modelName || settings?.agent.model || null;
|
||||
function toModelBadgeInfo(
|
||||
modelName: string | null,
|
||||
settings: SettingsPayload | null,
|
||||
modelPreset: string | null = null,
|
||||
): ModelBadgeInfo {
|
||||
const scopedPreset = modelPreset?.trim() || null;
|
||||
const preset = modelPresetForBadge(settings, scopedPreset);
|
||||
const model = scopedPreset
|
||||
? preset?.model || null
|
||||
: modelName || settings?.agent.model || null;
|
||||
const label = toModelBadgeLabel(model);
|
||||
const provider = resolvedModelProvider(settings, model);
|
||||
const rawProvider = preset?.provider
|
||||
|| (!scopedPreset ? settings?.agent.provider : null)
|
||||
|| null;
|
||||
const provider = rawProvider === "auto"
|
||||
? preset?.resolved_provider
|
||||
|| (!scopedPreset ? settings?.agent.resolved_provider : null)
|
||||
|| null
|
||||
: rawProvider || inferProviderFromModelName(model);
|
||||
const providerRow = provider
|
||||
? settings?.providers.find((item) => item.name === provider)
|
||||
: null;
|
||||
@@ -459,9 +471,10 @@ export function ThreadShell({
|
||||
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
const wasShowingHeroComposerRef = useRef(showHeroComposer);
|
||||
const sessionModelPreset = session?.modelPreset?.trim() || null;
|
||||
const modelBadge = useMemo(
|
||||
() => toModelBadgeInfo(modelName, settings),
|
||||
[modelName, settings],
|
||||
() => toModelBadgeInfo(modelName, settings, sessionModelPreset),
|
||||
[modelName, sessionModelPreset, settings],
|
||||
);
|
||||
const modelBadgeLabel = modelBadge.needsSetup
|
||||
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function listSessions(
|
||||
updated_at: string | null;
|
||||
title?: string;
|
||||
preview?: string;
|
||||
model_preset?: string | null;
|
||||
run_started_at?: number | null;
|
||||
workspace_scope?: WorkspaceScopePayload | null;
|
||||
};
|
||||
@@ -148,6 +149,7 @@ export async function listSessions(
|
||||
updatedAt: s.updated_at,
|
||||
title: s.title ?? "",
|
||||
preview: s.preview ?? "",
|
||||
modelPreset: s.model_preset ?? null,
|
||||
runStartedAt: s.run_started_at ?? null,
|
||||
workspaceScope: s.workspace_scope ?? null,
|
||||
}));
|
||||
|
||||
@@ -245,6 +245,8 @@ export interface ChatSummary {
|
||||
updatedAt: string | null;
|
||||
title?: string;
|
||||
preview: string;
|
||||
/** Model preset persisted for this session; null means it still follows the global default. */
|
||||
modelPreset?: string | null;
|
||||
/** Unix epoch seconds when this session currently has a turn in flight. */
|
||||
runStartedAt?: number | null;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
@@ -406,6 +408,7 @@ export interface SettingsPayload {
|
||||
is_default: boolean;
|
||||
model: string;
|
||||
provider: string;
|
||||
resolved_provider?: string | null;
|
||||
max_tokens: number;
|
||||
context_window_tokens: number;
|
||||
temperature: number;
|
||||
|
||||
@@ -797,6 +797,7 @@ describe("webui API helpers", () => {
|
||||
created_at: "2026-05-01T10:00:00",
|
||||
updated_at: "2026-05-01T10:01:00",
|
||||
title: "优化 WebUI 标题",
|
||||
model_preset: "fast",
|
||||
run_started_at: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
@@ -808,6 +809,7 @@ describe("webui API helpers", () => {
|
||||
key: "websocket:chat-1",
|
||||
title: "优化 WebUI 标题",
|
||||
preview: "",
|
||||
modelPreset: "fast",
|
||||
runStartedAt: 1_700_000_000,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -94,7 +94,7 @@ function expectSendMessageWithTurn(
|
||||
);
|
||||
}
|
||||
|
||||
function session(chatId: string) {
|
||||
function session(chatId: string, modelPreset?: string | null) {
|
||||
return {
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket" as const,
|
||||
@@ -102,6 +102,7 @@ function session(chatId: string) {
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
preview: "",
|
||||
modelPreset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +221,20 @@ function modelSettings(model: string, provider: string): SettingsPayload {
|
||||
};
|
||||
}
|
||||
|
||||
function settingsWithFastPreset(): SettingsPayload {
|
||||
const settings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||
settings.model_presets.push({
|
||||
...settings.model_presets[0]!,
|
||||
name: "fast",
|
||||
label: "Fast",
|
||||
active: false,
|
||||
is_default: false,
|
||||
model: "openai-codex/gpt-5.5",
|
||||
provider: "openai_codex",
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
describe("ThreadShell", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
@@ -341,6 +356,61 @@ describe("ThreadShell", () => {
|
||||
expect(await screen.findByTestId("composer-model-logo-openai_codex")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resolves the composer model from the active session preset", async () => {
|
||||
const client = makeClient();
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-fast", "fast")}
|
||||
title="Fast session"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={settingsWithFastPreset()}
|
||||
/>,
|
||||
"deepseek-v4-pro",
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the backend-resolved provider for an auto session preset", async () => {
|
||||
const client = makeClient();
|
||||
const settings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||
settings.providers.push({
|
||||
name: "companyproxy",
|
||||
label: "Company Proxy",
|
||||
configured: true,
|
||||
});
|
||||
settings.model_presets.push({
|
||||
...settings.model_presets[0]!,
|
||||
name: "fast",
|
||||
label: "Fast",
|
||||
active: false,
|
||||
is_default: false,
|
||||
model: "companyproxy/gpt-4",
|
||||
provider: "auto",
|
||||
resolved_provider: "companyproxy",
|
||||
});
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-auto", "fast")}
|
||||
title="Auto provider session"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={settings}
|
||||
/>,
|
||||
"deepseek-v4-pro",
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("gpt-4 · Company Proxy")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens model settings from the unconfigured model badge", async () => {
|
||||
const client = makeClient();
|
||||
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
||||
|
||||
Reference in New Issue
Block a user