Skip to content
Open
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ services:
- LANGFLOW_INGEST_FLOW_ID=${LANGFLOW_INGEST_FLOW_ID}
- LANGFLOW_URL_INGEST_FLOW_ID=${LANGFLOW_URL_INGEST_FLOW_ID}
- DISABLE_INGEST_WITH_LANGFLOW=${DISABLE_INGEST_WITH_LANGFLOW:-false}
- DISABLE_CHAT_WITH_LANGFLOW=${DISABLE_CHAT_WITH_LANGFLOW:-false}
- INGEST_SAMPLE_DATA=${INGEST_SAMPLE_DATA:-true}
- NUDGES_FLOW_ID=${NUDGES_FLOW_ID}
- OPENSEARCH_PORT=${OPENSEARCH_INTERNAL_PORT:-9200}
Expand Down
1 change: 1 addition & 0 deletions frontend/app/api/mutations/useUpdateSettingsMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface UpdateSettingsRequest {
// Agent settings
llm_model?: string;
llm_provider?: string;
disable_chat_with_langflow?: boolean;
system_prompt?: string;

// Knowledge settings
Expand Down
70 changes: 62 additions & 8 deletions frontend/app/api/queries/useGetNudgesQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useCallback, useRef } from "react";
import { useChat } from "@/contexts/chat-context";
import { useProviderHealthQuery } from "./useProviderHealthQuery";

type Nudge = string;

const DEFAULT_NUDGES: Nudge[] = [];

// An empty result means "nothing to suggest yet" — usually a corpus still being
// ingested — so it is worth re-asking for a while. But it is also what an empty
// knowledge base returns forever, so the retries must be bounded: unbounded
// polling re-POSTs every 5s for as long as the chat page is open, and each
// request costs a real LLM completion when nudges run without Langflow.
const MAX_EMPTY_POLLS = 5;
const EMPTY_POLL_INTERVAL_MS = 5000;

export interface NudgeFilters {
data_sources?: string[];
document_types?: string[];
Expand Down Expand Up @@ -42,7 +51,15 @@ export const useGetNudgesQuery = (
health === undefined ||
(health?.status === "healthy" && !health?.llm_error);

// Tracked per query key so changing chat/filters starts the budget over.
const pollKey = JSON.stringify([chatId, filters, limit, scoreThreshold]);
const emptyAttemptsRef = useRef({ key: pollKey, count: 0 });
if (emptyAttemptsRef.current.key !== pollKey) {
emptyAttemptsRef.current = { key: pollKey, count: 0 };
}

function cancel() {
emptyAttemptsRef.current.count = 0;
queryClient.removeQueries({
queryKey: ["nudges", chatId, filters, limit, scoreThreshold],
});
Expand Down Expand Up @@ -76,20 +93,33 @@ export const useGetNudgesQuery = (
body: JSON.stringify(requestBody),
signal: context.signal,
});
// A failed request is not "no nudges". Without this check an error body
// parses fine, yields no `response` key, and is cached as an empty list,
// which then keeps the retry loop below running indefinitely.
if (!response.ok) {
throw new Error(`Nudges request failed: ${response.status}`);
}

const data = await response.json();

if (data.response && typeof data.response === "string") {
return data.response.split("\n").filter(Boolean);
}
const nudges: Nudge[] =
data.response && typeof data.response === "string"
? data.response.split("\n").filter(Boolean)
: DEFAULT_NUDGES;

emptyAttemptsRef.current.count =
nudges.length === 0 ? emptyAttemptsRef.current.count + 1 : 0;

return DEFAULT_NUDGES;
return nudges;
} catch (error) {
// Ignore abort errors - these are expected when requests are cancelled
if (error instanceof Error && error.name === "AbortError") {
return DEFAULT_NUDGES;
}
console.error("Error getting nudges", error);
return DEFAULT_NUDGES;
// Rethrow so the query settles as an error rather than caching an empty
// list. `data` then stays undefined and the retry loop stops.
throw error;
}
}

Expand All @@ -107,15 +137,39 @@ export const useGetNudgesQuery = (
refetchOnMount: false, // Don't refetch on every mount
refetchOnWindowFocus: false, // Don't refetch when window regains focus
refetchInterval: (query) => {
// If data is empty, refetch every 5 seconds
// Retry while the result is empty, but only a bounded number of times.
const data = query.state.data;
return Array.isArray(data) && data.length === 0 ? 5000 : false;
if (!Array.isArray(data) || data.length > 0) {
return false;
}
return emptyAttemptsRef.current.count < MAX_EMPTY_POLLS
? EMPTY_POLL_INTERVAL_MS
: false;
},
...options,
enabled, // Override enabled after spreading options to ensure onboarding check is applied
},
queryClient,
);

return { data, isLoading, isError, error, refetch, isFetching, cancel };
// Callers refetch when the corpus changes (e.g. right after ingestion
// completes), which is exactly when a previously-empty result should start
// being worth retrying again. Re-arm the budget so the cap is not permanent.
const refetchNudges: typeof refetch = useCallback(
(...args) => {
emptyAttemptsRef.current.count = 0;
return refetch(...args);
},
[refetch],
);

return {
data,
isLoading,
isError,
error,
refetch: refetchNudges,
isFetching,
cancel,
};
};
1 change: 1 addition & 0 deletions frontend/app/api/queries/useGetSettingsQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { FunctionCall } from "@/app/chat/_types/types";
export interface AgentSettings {
llm_model?: string;
llm_provider?: string;
disable_chat_with_langflow?: boolean;
system_prompt?: string;
default_system_prompt?: string;
}
Expand Down
26 changes: 26 additions & 0 deletions frontend/app/settings/_components/agent-settings-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/contexts/auth-context";
import { useIsCloudBrand } from "@/contexts/brand-context";
Expand Down Expand Up @@ -179,6 +181,10 @@ export function AgentSettingsSection() {
);
};

const handleDisableChatWithLangflowChange = (checked: boolean) => {
updateSettingsMutation.mutate({ disable_chat_with_langflow: checked });
};

const handleEditInLangflow = (closeDialog: () => void) => {
trackButton({
CTA: "Edit in Langflow - Agent",
Expand Down Expand Up @@ -336,6 +342,26 @@ export function AgentSettingsSection() {
</div>
)}
</div>
<div className="flex items-center justify-between py-3 border-b border-border">
<div className="flex-1">
<Label
htmlFor="disable-chat-with-langflow"
className="text-base font-medium cursor-pointer pb-3"
>
Disable Langflow Chat
</Label>
<div className="text-sm text-muted-foreground">
Run chat, history and prompt suggestions in OpenRAG against the
language model above instead of sending them to Langflow flows.
</div>
</div>
<Switch
id="disable-chat-with-langflow"
checked={settings.agent?.disable_chat_with_langflow ?? false}
onCheckedChange={handleDisableChatWithLangflowChange}
disabled={updateSettingsMutation.isPending}
/>
</div>
<div className="space-y-2">
<LabelWrapper label="Agent Instructions" id="system-prompt">
<Textarea
Expand Down
25 changes: 23 additions & 2 deletions frontend/contexts/chat-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
useRef,
useState,
} from "react";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { INITIAL_ASSISTANT_MESSAGE } from "@/app/chat/_types/types";
import { useOnboardingState } from "@/hooks/use-onboarding-state";

Expand Down Expand Up @@ -99,7 +100,26 @@ interface ChatProviderProps {
}

export function ChatProvider({ children }: ChatProviderProps) {
const [endpoint, setEndpoint] = useState<EndpointType>("langflow");
// Langflow stays the default until settings load. agent.disable_chat_with_langflow
// then switches new sessions to the langflowless /chat endpoint, so a deployment
// that runs without Langflow doesn't get a UI that posts to it. Any explicit
// choice — the endpoint toggle, or loading a conversation that belongs to one
// endpoint — wins over the setting for the rest of the session.
const [endpoint, setEndpointState] = useState<EndpointType>("langflow");
const endpointChosen = useRef(false);
const { data: settings } = useGetSettingsQuery();
const disableChatWithLangflow = settings?.agent?.disable_chat_with_langflow;

const setEndpoint = useCallback((next: EndpointType) => {
endpointChosen.current = true;
setEndpointState(next);
}, []);

useEffect(() => {
if (endpointChosen.current || disableChatWithLangflow === undefined) return;
setEndpointState(disableChatWithLangflow ? "chat" : "langflow");
}, [disableChatWithLangflow]);

const [currentConversationId, setCurrentConversationId] = useState<
string | null
>(null);
Expand Down Expand Up @@ -229,7 +249,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
// Clear conversation docs to prevent duplicates when switching conversations
setConversationDocs([]);
},
[conversationData?.response_id],
[conversationData?.response_id, setEndpoint],
);

const startNewConversation = useCallback(async () => {
Expand Down Expand Up @@ -422,6 +442,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
isOnboardingComplete,
setOnboardingComplete,
loading,
setEndpoint,
],
);

Expand Down
11 changes: 11 additions & 0 deletions frontend/hooks/useChatStreaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ export function useChatStreaming({
newResponseId = chunk.id;
} else if (chunk.response_id) {
newResponseId = chunk.response_id;
} else if (
chunk.type === "response.completed" &&
chunk.response?.id
) {
// An OpenAI Responses stream carries no top-level id — it is
// on the response object of the terminal event. Langflow's SSE
// wrapper does send one, so only the direct (langflowless)
// path was affected: without this the caller never learns the
// id, never sends previous_response_id, and every question
// starts a fresh conversation with no memory of the last turn.
newResponseId = chunk.response.id;
}

parseOpenAIChatChunk(chunk, content, currentFunctionCalls) ||
Expand Down
Loading
Loading