mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 17:03:24 +00:00
⚡ feat: Immediate Conversation Title Generation (#13395)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* ⚡ feat: Immediate Conversation Title Generation Generate conversation titles as soon as the request is made (in parallel with the response, from the user's first message) as the new default, fixing the #13318 race where a transient /gen_title 404 left new chats stuck on "New Chat". - Add per-endpoint `titleTiming` ('immediate' | 'final') to baseEndpointSchema; `endpoints.all` acts as the global default, unset = immediate. Resolve via a new `resolveTitleTiming` helper (`all` takes precedence). - Fire title generation in parallel with `sendMessage`; `titleConvo` waits (bounded, abortable) for the agent run and titles from the user input only. Persist after the conversation row exists; defer `disposeClient` until the title settles. - Expose `titleGenerationTiming` via startup config; `useTitleGeneration` fetches eagerly in immediate mode with a bounded 404 retry and never treats a transient 404 as final. Skip title queueing for temporary conversations. - Supersedes #13329 while incorporating its bounded 404-retry. * 🩹 fix: Address Copilot review findings on title timing - Guard against an undefined conversationId in addTitle (skip + warn) so the gen_title cache key can't collide as `userId-undefined` and saveConvo is never called without a conversationId. - Gate the title `useQueries` on `enabled` so no /gen_title request fires while unauthenticated (e.g. after logout) even if the module queue holds IDs. - Drop the stale `conversationId` param from the titleConvo JSDoc. - Add a regression test for the undefined-conversationId guard. * 🧵 fix: Harden immediate-title edge cases from codex review - Cancel in-flight immediate title generation when the request aborts: thread job.abortController.signal through addTitle so pressing Stop on a new chat neither consumes the title model nor surfaces a title for a cancelled turn. - Preserve a locally-applied title when the final SSE event's conversation carries no title yet (built before the title was saved), so long immediate-mode responses no longer revert the chat to "New Chat" until reload. - Guarantee one full post-completion gen_title fetch cycle before giving up, so a `final`-mode title (generated only after the stream ends) is still fetched under a global `immediate` default instead of being stranded. - Add regression tests for the abort propagation and the undefined-conversationId guard. * 🔁 fix: Correct title abort, post-completion refetch, and replacement ordering Follow-up to codex review of the immediate-title fixes: - Use a dedicated title AbortController instead of `job.abortController`. The latter is also aborted by `completeJob` on *successful* completion, which cancelled any title slower than a short response. The title is now cancelled only on a real user Stop or when the stream is replaced; a completed-then- aborted title is discarded (no save, cache cleared) rather than persisted. - Reset (not remove) the post-completion title query: `resetQueries` refetches the mounted observer with a fresh retry budget, whereas `removeQueries` left it stuck in its error state, so the promised post-completion cycle never ran. - Run the job-replacement check before resolving `convoReady`, and on a replaced stream cancel/discard the stale title so a discarded prompt can't persist a title. * 🧷 fix: Tighten title abort ordering and endpoint-level timing resolution Follow-up to codex review: - Abort the title controller before resolving `convoReady` on a stopped turn, so the title task can't resume and persist before the later abort. - Cancel the title and unblock its waits on ANY send failure (not just user aborts): a preflight/quota failure before the run exists otherwise hangs `_waitForRun`, deferring client disposal until the 45s title timeout. - Resolve `titleTiming` for custom endpoints via `getCustomEndpointConfig` (their config lives under `endpoints.custom[]`, not `endpoints[endpoint]`). - Derive the startup `titleGenerationTiming` via `resolveTitleTiming` for the agents endpoint so an endpoint-level `final` (without `endpoints.all`) is honored client-side instead of defaulting to immediate and burning eager gen_title polls. * 🪢 fix: Per-agent title timing and safer abort/replacement handling Follow-up to codex review: - Resolve `titleTiming` from the agent's actual endpoint after initialization, so a per-endpoint `final` override on a custom/provider endpoint backing an (ephemeral) agent is honored instead of always using the `agents` endpoint's value. - Don't preserve a locally-fetched title on a stopped (unfinished) turn: the server cancels and discards that title, so keeping it client-side would diverge from server state and leave the stopped chat titled until reload. - On abort/replacement, only delete the cached title if it still holds THIS task's value — a replacement stream shares the `userId-conversationId` key and may have already cached its own valid title that must not be removed. * 🪞 fix: Mirror AgentClient title-config resolution for titleTiming Per maintainer guidance, keep titleTiming resolution identical to how `AgentClient#titleConvo` already resolves the endpoint config — `endpoints.all` is the intended global override and the agent's actual provider endpoint is used: - Resolve via `endpoints.all ?? endpoints[endpoint] ?? getProviderConfig(endpoint) .customEndpointConfig` (was using `getCustomEndpointConfig` directly). Going through `getProviderConfig` picks up its case-insensitive fallback for normalized provider names (e.g. `openrouter` → `OpenRouter`), so a custom endpoint's `titleTiming` is honored like its other title settings. - Add `titleTiming` to the Azure endpoint schema `.pick()` so `endpoints.azureOpenAI.titleTiming` is no longer silently stripped by Zod. Note: per-endpoint title settings being skipped when `endpoints.all` is present is the existing, intended global-override behavior — not changed here. * 🧪 test: Cover useTitleGeneration effect logic (integration) Adds a deterministic white-box integration test that drives the real hook's React effects with a controllable react-query surface, locking down the stateful decisions that previously had no coverage: - immediate mode fetches a queued conversation while its stream is still active - final mode gates until the stream completes, then becomes eligible - success applies the fetched title to the conversation caches - a 404 while active defers (removeQueries) instead of giving up - a 404 after completion forces a fresh fetch via resetQueries (post-completion remount) * feat: Stream immediate title events * style: Format title SSE handler * test: Preserve data-provider exports in OAuth mock * test: Isolate OAuth route API mock * test: Keep OAuth callback factory capture * fix: Replay streamed title events on resume * fix: Honor agents title timing precedence * style: Format title timing fixes
This commit is contained in:
parent
b45e4aeae5
commit
2ef7bdfbc2
22 changed files with 1437 additions and 52 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { Providers } from '@librechat/agents';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { getProviderConfig, providerConfigMap } from './providers';
|
||||
import { getProviderConfig, providerConfigMap, resolveTitleTiming } from './providers';
|
||||
|
||||
const buildAppConfig = (
|
||||
customEndpoints: Array<{ name: string; baseURL?: string; apiKey?: string }>,
|
||||
|
|
@ -97,3 +97,94 @@ describe('getProviderConfig', () => {
|
|||
).toThrow('Provider openrouter not supported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTitleTiming', () => {
|
||||
const withEndpoints = (endpoints: Record<string, unknown>): AppConfig =>
|
||||
({ endpoints }) as unknown as AppConfig;
|
||||
|
||||
it("defaults to 'immediate' when no config is provided", () => {
|
||||
expect(resolveTitleTiming({})).toBe('immediate');
|
||||
});
|
||||
|
||||
it("defaults to 'immediate' when endpoints is missing", () => {
|
||||
expect(resolveTitleTiming({ appConfig: {} as AppConfig })).toBe('immediate');
|
||||
});
|
||||
|
||||
it("defaults to 'immediate' when no titleTiming is set", () => {
|
||||
const appConfig = withEndpoints({ [EModelEndpoint.agents]: { titleConvo: true } });
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: EModelEndpoint.agents })).toBe('immediate');
|
||||
});
|
||||
|
||||
it("returns 'final' from the global `all` config", () => {
|
||||
const appConfig = withEndpoints({ all: { titleTiming: 'final' } });
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: EModelEndpoint.agents })).toBe('final');
|
||||
});
|
||||
|
||||
it("returns 'final' from the per-endpoint config when `all` is unset", () => {
|
||||
const appConfig = withEndpoints({ [EModelEndpoint.agents]: { titleTiming: 'final' } });
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: EModelEndpoint.agents })).toBe('final');
|
||||
});
|
||||
|
||||
it('lets `all` take precedence over the per-endpoint value', () => {
|
||||
const appConfig = withEndpoints({
|
||||
all: { titleTiming: 'immediate' },
|
||||
[EModelEndpoint.agents]: { titleTiming: 'final' },
|
||||
});
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: EModelEndpoint.agents })).toBe('immediate');
|
||||
});
|
||||
|
||||
it('does not let unrelated `all` config block a per-endpoint value', () => {
|
||||
const appConfig = withEndpoints({
|
||||
all: { titleConvo: true },
|
||||
[EModelEndpoint.agents]: { titleTiming: 'final' },
|
||||
});
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: EModelEndpoint.agents })).toBe('final');
|
||||
});
|
||||
|
||||
it('checks endpoint candidates in order before provider fallback', () => {
|
||||
const appConfig = withEndpoints({
|
||||
[EModelEndpoint.agents]: { titleTiming: 'final' },
|
||||
[EModelEndpoint.openAI]: { titleTiming: 'immediate' },
|
||||
});
|
||||
expect(
|
||||
resolveTitleTiming({
|
||||
appConfig,
|
||||
endpoint: [EModelEndpoint.agents, EModelEndpoint.openAI],
|
||||
}),
|
||||
).toBe('final');
|
||||
});
|
||||
|
||||
it('falls back to backing provider timing when agents has no titleTiming', () => {
|
||||
const appConfig = withEndpoints({
|
||||
[EModelEndpoint.agents]: { titleConvo: true },
|
||||
[EModelEndpoint.openAI]: { titleTiming: 'final' },
|
||||
});
|
||||
expect(
|
||||
resolveTitleTiming({
|
||||
appConfig,
|
||||
endpoint: [EModelEndpoint.agents, EModelEndpoint.openAI],
|
||||
}),
|
||||
).toBe('final');
|
||||
});
|
||||
|
||||
it("returns 'immediate' for an endpoint with no override and no `all`", () => {
|
||||
const appConfig = withEndpoints({ [EModelEndpoint.openAI]: { titleTiming: 'final' } });
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: EModelEndpoint.agents })).toBe('immediate');
|
||||
});
|
||||
|
||||
it("resolves 'final' from a custom endpoint config (endpoints.custom[])", () => {
|
||||
const appConfig = withEndpoints({
|
||||
[EModelEndpoint.custom]: [{ name: 'MyProvider', titleTiming: 'final' }],
|
||||
});
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: 'MyProvider' })).toBe('final');
|
||||
});
|
||||
|
||||
it('resolves a normalized custom provider name (openrouter -> OpenRouter)', () => {
|
||||
const appConfig = withEndpoints({
|
||||
[EModelEndpoint.custom]: [
|
||||
{ name: 'OpenRouter', baseURL: 'https://openrouter.ai/api/v1', titleTiming: 'final' },
|
||||
],
|
||||
});
|
||||
expect(resolveTitleTiming({ appConfig, endpoint: 'openrouter' })).toBe('final');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,69 @@ export const providerConfigMap: Record<string, InitializeFn> = {
|
|||
[EModelEndpoint.anthropic]: initializeAnthropic,
|
||||
};
|
||||
|
||||
export type TitleTiming = 'immediate' | 'final';
|
||||
|
||||
/**
|
||||
* Resolves when conversation titles are generated for a given endpoint.
|
||||
*
|
||||
* `endpoints.all.titleTiming`, when present, is the global override. Otherwise,
|
||||
* endpoint candidates are checked in order so the public endpoint (for example
|
||||
* `agents`) can override the backing provider, with provider/custom config used
|
||||
* as a fallback. Resolving custom providers via `getProviderConfig` picks up its
|
||||
* case-insensitive fallback for normalized provider names (e.g. `openrouter` →
|
||||
* `OpenRouter`). Defaults to `immediate`.
|
||||
*/
|
||||
export function resolveTitleTiming({
|
||||
appConfig,
|
||||
endpoint,
|
||||
}: {
|
||||
appConfig?: AppConfig;
|
||||
endpoint?: string | Array<string | undefined>;
|
||||
}): TitleTiming {
|
||||
const endpoints = appConfig?.endpoints;
|
||||
const resolveConfiguredTiming = (config?: Partial<TEndpoint>): TitleTiming | undefined =>
|
||||
config?.titleTiming === 'final' || config?.titleTiming === 'immediate'
|
||||
? config.titleTiming
|
||||
: undefined;
|
||||
|
||||
const globalTiming = resolveConfiguredTiming(endpoints?.all);
|
||||
if (globalTiming) {
|
||||
return globalTiming;
|
||||
}
|
||||
|
||||
const endpointCandidates = (Array.isArray(endpoint) ? endpoint : [endpoint]).filter(
|
||||
(value): value is string => !!value,
|
||||
);
|
||||
|
||||
for (const endpointCandidate of endpointCandidates) {
|
||||
const endpointConfig = endpoints?.[endpointCandidate as keyof NonNullable<typeof endpoints>] as
|
||||
| Partial<TEndpoint>
|
||||
| undefined;
|
||||
const endpointTiming = resolveConfiguredTiming(endpointConfig);
|
||||
if (endpointTiming) {
|
||||
return endpointTiming;
|
||||
}
|
||||
}
|
||||
|
||||
for (const endpointCandidate of endpointCandidates) {
|
||||
if (!appConfig) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const providerTiming = resolveConfiguredTiming(
|
||||
getProviderConfig({ provider: endpointCandidate, appConfig }).customEndpointConfig,
|
||||
);
|
||||
if (providerTiming) {
|
||||
return providerTiming;
|
||||
}
|
||||
} catch {
|
||||
// Unsupported providers fall back to the default timing.
|
||||
}
|
||||
}
|
||||
|
||||
return 'immediate';
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from getProviderConfig
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -961,6 +961,7 @@ class GenerationJobManagerClass {
|
|||
this.jobStore.recordActivity?.(streamId);
|
||||
|
||||
await this.trackUserMessage(streamId, event);
|
||||
await this.trackTitleEvent(streamId, event);
|
||||
|
||||
// For Redis mode, persist chunk for later reconstruction (fire-and-forget for resumability)
|
||||
if (this._isRedis) {
|
||||
|
|
@ -1043,6 +1044,21 @@ class GenerationJobManagerClass {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the last title event so resume sync can replay it. Content
|
||||
* aggregation only reconstructs message parts, so UI-only events need their
|
||||
* own metadata slot.
|
||||
*/
|
||||
private async trackTitleEvent(streamId: string, event: t.ServerSentEvent): Promise<void> {
|
||||
if (!('event' in event) || event.event !== 'title') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.jobStore.updateJob(streamId, {
|
||||
titleEvent: JSON.stringify(event),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist user message metadata from the created event.
|
||||
* Awaited in emitChunk so the HSET commits before the PUBLISH,
|
||||
|
|
@ -1152,6 +1168,14 @@ class GenerationJobManagerClass {
|
|||
const result = await this.jobStore.getContentParts(streamId);
|
||||
const aggregatedContent = result?.content ?? [];
|
||||
const runSteps = await this.jobStore.getRunSteps(streamId);
|
||||
let titleEvent: t.ResumeState['titleEvent'];
|
||||
if (jobData.titleEvent) {
|
||||
try {
|
||||
titleEvent = JSON.parse(jobData.titleEvent) as t.ResumeState['titleEvent'];
|
||||
} catch {
|
||||
// Ignore malformed persisted title events.
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`[GenerationJobManager] getResumeState:`, {
|
||||
streamId,
|
||||
|
|
@ -1166,6 +1190,7 @@ class GenerationJobManagerClass {
|
|||
responseMessageId: jobData.responseMessageId,
|
||||
conversationId: jobData.conversationId,
|
||||
sender: jobData.sender,
|
||||
titleEvent,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1356,6 +1356,28 @@ describe('GenerationJobManager Integration Tests', () => {
|
|||
await manager.destroy();
|
||||
});
|
||||
|
||||
test('should include emitted title event in resume state', async () => {
|
||||
const manager = createInMemoryManager();
|
||||
const streamId = `title-resume-${Date.now()}`;
|
||||
await manager.createJob(streamId, 'user-1', streamId);
|
||||
|
||||
const titleEvent = {
|
||||
event: 'title',
|
||||
data: {
|
||||
conversationId: streamId,
|
||||
title: 'Resumed Title',
|
||||
},
|
||||
} satisfies ServerSentEvent;
|
||||
|
||||
await manager.emitChunk(streamId, titleEvent);
|
||||
|
||||
const resumeState = await manager.getResumeState(streamId);
|
||||
|
||||
expect(resumeState?.titleEvent).toEqual(titleEvent);
|
||||
|
||||
await manager.destroy();
|
||||
});
|
||||
|
||||
test('should replay buffer by default when no options are passed', async () => {
|
||||
const manager = createInMemoryManager();
|
||||
const streamId = `replay-buf-${Date.now()}`;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ export interface SerializableJobData {
|
|||
/** Serialized final event for replay */
|
||||
finalEvent?: string;
|
||||
|
||||
/** Serialized title event for replay during active-stream resume */
|
||||
titleEvent?: string;
|
||||
|
||||
/** Endpoint metadata for abort handling - avoids storing functions */
|
||||
endpoint?: string;
|
||||
iconURL?: string;
|
||||
|
|
@ -139,6 +142,13 @@ export interface ResumeState {
|
|||
responseMessageId?: string;
|
||||
conversationId?: string;
|
||||
sender?: string;
|
||||
titleEvent?: {
|
||||
event: 'title';
|
||||
data?: {
|
||||
conversationId?: string;
|
||||
title?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue