mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🛬 fix: Prevent Viewed Conversations from Re-Arming the Soft Default Spec (#13699)
This commit is contained in:
parent
a8a63604b9
commit
2d6b7df3ce
3 changed files with 108 additions and 18 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { TModelSpec, TStartupConfig, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { getDefaultModelSpec } from '../endpoints';
|
||||
|
||||
|
|
@ -51,12 +51,13 @@ const writeLastModel = (endpoint: string, model: string) => {
|
|||
};
|
||||
|
||||
/** Mirrors what the conversation effect persists after a spec preset is applied */
|
||||
const persistAppliedSpec = (spec: TModelSpec) => {
|
||||
const persistAppliedSpec = (spec: TModelSpec, conversationId: string = Constants.NEW_CONVO) => {
|
||||
localStorage.setItem(LocalStorageKeys.LAST_SPEC, spec.name);
|
||||
writeLastModel(spec.preset.endpoint as string, spec.preset.model as string);
|
||||
localStorage.setItem(
|
||||
`${LocalStorageKeys.LAST_CONVO_SETUP}_0`,
|
||||
JSON.stringify({
|
||||
conversationId,
|
||||
endpoint: spec.preset.endpoint,
|
||||
model: spec.preset.model,
|
||||
spec: spec.name,
|
||||
|
|
@ -225,7 +226,7 @@ describe('getDefaultModelSpec', () => {
|
|||
expect(result).toEqual({ last: otherSpec });
|
||||
});
|
||||
|
||||
it('yields to a stored agent even when it matches the soft default agent spec', () => {
|
||||
it('treats a matching stored agent as residue when the soft default points to an agent', () => {
|
||||
const softAgentSpec = createModelSpec('soft-agent-spec', {
|
||||
softDefault: true,
|
||||
preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_soft' },
|
||||
|
|
@ -237,10 +238,10 @@ describe('getDefaultModelSpec', () => {
|
|||
fullEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toEqual({ softDefault: softAgentSpec });
|
||||
});
|
||||
|
||||
it('yields to a stored model selection matching the soft default preset', () => {
|
||||
it('treats a stored model matching the soft default preset as residue', () => {
|
||||
localStorage.setItem(
|
||||
LocalStorageKeys.LAST_MODEL,
|
||||
JSON.stringify({ [softSpec.preset.endpoint as string]: softSpec.preset.model }),
|
||||
|
|
@ -251,6 +252,41 @@ describe('getDefaultModelSpec', () => {
|
|||
fullEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ softDefault: softSpec });
|
||||
});
|
||||
|
||||
it('stays soft after the first conversation is sent', () => {
|
||||
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
|
||||
fullEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ softDefault: softSpec });
|
||||
});
|
||||
|
||||
it('does not re-arm from viewing an old soft conversation after an ephemeral pick', () => {
|
||||
persistEphemeralSelection(EModelEndpoint.anthropic, 'claude-sonnet-4-6');
|
||||
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
|
||||
fullEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not re-arm from viewing an old soft conversation after an agent pick', () => {
|
||||
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_abc');
|
||||
persistAppliedSpec(softSpec, 'a8b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d');
|
||||
|
||||
const result = getDefaultModelSpec(
|
||||
createStartupConfig([otherSpec, softSpec], { prioritize: false }),
|
||||
fullEndpointsConfig,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ interface InitiatedTemplateResult {
|
|||
|
||||
type StoredModelSelection = Pick<
|
||||
t.TConversation,
|
||||
'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id'
|
||||
'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id' | 'conversationId'
|
||||
>;
|
||||
|
||||
function hasSelectionValue(value?: string | null): boolean {
|
||||
|
|
@ -160,14 +160,15 @@ function parseStoredModelSelection(
|
|||
}
|
||||
}
|
||||
|
||||
function hasStoredPrefixValue(prefix: string): boolean {
|
||||
function hasStoredPrefixValue(prefix: string, ignoreValue?: string | null): boolean {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key?.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasSelectionValue(localStorage.getItem(key))) {
|
||||
const value = localStorage.getItem(key);
|
||||
if (hasSelectionValue(value) && value !== ignoreValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -175,7 +176,7 @@ function hasStoredPrefixValue(prefix: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function hasStoredModelValue(): boolean {
|
||||
function hasStoredModelValue(softPreset?: t.TModelSpecPreset): boolean {
|
||||
const storedModelValue = localStorage.getItem(LocalStorageKeys.LAST_MODEL);
|
||||
if (!storedModelValue) {
|
||||
return false;
|
||||
|
|
@ -183,7 +184,11 @@ function hasStoredModelValue(): boolean {
|
|||
|
||||
try {
|
||||
const storedModels = JSON.parse(storedModelValue) as Record<string, string | null | undefined>;
|
||||
return Object.values(storedModels).some(hasSelectionValue);
|
||||
return Object.entries(storedModels).some(
|
||||
([endpoint, model]) =>
|
||||
hasSelectionValue(model) &&
|
||||
!(endpoint === softPreset?.endpoint && model === softPreset?.model),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -205,22 +210,23 @@ export function hasModelSelection(selection?: Partial<StoredModelSelection> | nu
|
|||
|
||||
/**
|
||||
* Whether localStorage holds a model selection the user actually made.
|
||||
* Only spec entries naming `softDefaultSpec` are residue of the soft default
|
||||
* itself — selections that merely match its preset still count as user choices.
|
||||
* State matching what applying `softDefaultSpec` writes is residue of the
|
||||
* soft default itself, not evidence of a user choice, and is ignored.
|
||||
*/
|
||||
function hasStoredModelSelection(softDefaultSpec?: t.TModelSpec): boolean {
|
||||
const softPreset = softDefaultSpec?.preset;
|
||||
const lastSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC);
|
||||
if (hasSelectionValue(lastSpecName) && lastSpecName !== softDefaultSpec?.name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasStoredModelValue()) {
|
||||
if (hasStoredModelValue(softPreset)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX) ||
|
||||
hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX)
|
||||
hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX, softPreset?.agent_id) ||
|
||||
hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX, softPreset?.assistant_id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -406,7 +412,9 @@ export function applyModelSpecEphemeralAgent({
|
|||
/**
|
||||
* Gets default model spec from config and user preferences.
|
||||
* Priority: hard admin default → prior user selection → soft default.
|
||||
* The soft default yields only to selections the user actually made, and acts
|
||||
* The soft default yields only to selections the user actually made — its
|
||||
* stored state counts as the live selection only when set on a new chat, so
|
||||
* merely viewing an old soft-spec conversation never re-arms it — and it acts
|
||||
* as the fallback default when no ephemeral endpoint → model options exist.
|
||||
* Legacy first-spec prioritization remains only when no soft default is configured.
|
||||
*/
|
||||
|
|
@ -434,7 +442,10 @@ export function getDefaultModelSpec(
|
|||
if (!softDefaultSpec) {
|
||||
return;
|
||||
}
|
||||
if (lastConversationSetup?.spec === softDefaultSpec.name) {
|
||||
if (
|
||||
lastConversationSetup?.spec === softDefaultSpec.name &&
|
||||
lastConversationSetup?.conversationId === Constants.NEW_CONVO
|
||||
) {
|
||||
return { softDefault: softDefaultSpec };
|
||||
}
|
||||
const ephemeralOptions = hasEphemeralModelOptions({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { NEW_CHAT_PATH, getAccessToken, requestJson } from './helpers';
|
||||
import { NEW_CHAT_PATH, getAccessToken, mockReply, requestJson, sendMessage } from './helpers';
|
||||
|
||||
/** Label of the `softDefault: true` spec in e2e/config/librechat.e2e.yaml. */
|
||||
const SOFT_DEFAULT_LABEL = 'E2E Soft Default';
|
||||
|
|
@ -53,6 +53,18 @@ async function selectEphemeralModel(page: Page) {
|
|||
await expect(modelTrigger(page)).toContainText(EPHEMERAL_ENDPOINT.model);
|
||||
}
|
||||
|
||||
async function sendAndAwaitReply(page: Page, text: string) {
|
||||
const response = await sendMessage(page, text);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
|
||||
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
async function newChat(page: Page) {
|
||||
await page.getByTestId('new-chat-button').click();
|
||||
await expect(page).toHaveURL(/\/c\/new/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('soft default model spec', () => {
|
||||
test('applies the soft default on a fresh instance and stays applied across reloads', async ({
|
||||
page,
|
||||
|
|
@ -103,4 +115,35 @@ test.describe('soft default model spec', () => {
|
|||
await expect(page).toHaveURL(/\/c\/new/, { timeout: 15000 });
|
||||
await expect(modelTrigger(page)).toContainText(EPHEMERAL_ENDPOINT.model, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test('stays soft on New Chat after the first conversation is sent', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await startFresh(page);
|
||||
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
|
||||
|
||||
await sendAndAwaitReply(page, 'first soft conversation');
|
||||
|
||||
await newChat(page);
|
||||
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test('viewing an old soft conversation does not re-arm it over a prior selection', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await startFresh(page);
|
||||
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
|
||||
|
||||
await sendAndAwaitReply(page, 'soft history conversation');
|
||||
const softConvoUrl = page.url();
|
||||
|
||||
await newChat(page);
|
||||
await selectEphemeralModel(page);
|
||||
|
||||
await page.goto(softConvoUrl, { timeout: 10000 });
|
||||
await expect(modelTrigger(page)).toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
|
||||
|
||||
await newChat(page);
|
||||
await expect(modelTrigger(page)).not.toContainText(SOFT_DEFAULT_LABEL, { timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue