feat(agent): make model presets session-scoped (#4866)

This commit is contained in:
chengyongru
2026-07-23 00:38:49 +08:00
committed by GitHub
parent 66690fdb0c
commit c22efb5f7a
41 changed files with 1140 additions and 155 deletions
+28 -15
View File
@@ -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" })
+2
View File
@@ -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,
}));
+3
View File
@@ -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;
+2
View File
@@ -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,
},
]);
+71 -1
View File
@@ -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");