feat(suggest_agent): stop frontend from fetching when suggestions disabled (#3599)

* feat(suggest_agent): stop frontend from fetching when suggestions disabled

* wait for suggestions config to load before fetching

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Chetan Sharma 2026-06-19 15:47:21 +05:30 committed by GitHub
parent 525ec0a00d
commit 3e055d8f43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 123 additions and 2 deletions

View File

@ -272,7 +272,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |

View File

@ -31,6 +31,10 @@ class SuggestionsResponse(BaseModel):
suggestions: list[str] = Field(default_factory=list, description="Suggested follow-up questions")
class SuggestionsConfigResponse(BaseModel):
enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally")
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
@ -122,6 +126,18 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str:
return "\n".join(parts).strip()
@router.get(
"/suggestions/config",
response_model=SuggestionsConfigResponse,
summary="Get Suggestions Configuration",
description="Returns the global configuration for follow-up suggestions.",
)
async def get_suggestions_config(
config: AppConfig = Depends(get_config),
) -> SuggestionsConfigResponse:
return SuggestionsConfigResponse(enabled=config.suggestions.enabled)
@router.post(
"/threads/{thread_id}/suggestions",
response_model=SuggestionsResponse,

View File

@ -192,3 +192,17 @@ def test_generate_suggestions_returns_empty_when_disabled(monkeypatch):
assert result.suggestions == []
fake_model.ainvoke.assert_not_called()
def test_get_suggestions_config():
"""Ensure the GET /config endpoint correctly returns the boolean state."""
# Test when enabled
mock_config_true = SimpleNamespace(suggestions=SimpleNamespace(enabled=True))
result_true = asyncio.run(suggestions.get_suggestions_config(config=mock_config_true))
assert result_true.enabled is True
# Test when disabled
mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False))
result_false = asyncio.run(suggestions.get_suggestions_config(config=mock_config_false))
assert result_false.enabled is False

View File

@ -62,6 +62,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
@ -203,6 +204,7 @@ export function InputBox({
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]);
const { data: suggestionsConfig } = useSuggestionsConfig();
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const [textareaFocused, setTextareaFocused] = useState(false);
@ -524,6 +526,9 @@ export function InputBox({
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
return;
}
if (suggestionsConfig === undefined) {
return;
}
lastGeneratedForAiIdRef.current = lastAiId;
const recent = messagesRef.current
@ -540,6 +545,11 @@ export function InputBox({
return;
}
if (!suggestionsConfig?.enabled) {
setFollowups([]);
return;
}
const controller = new AbortController();
setFollowupsHidden(false);
setFollowupsLoading(true);
@ -576,7 +586,14 @@ export function InputBox({
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, status, threadId]);
}, [
context.model_name,
disabled,
isMock,
status,
threadId,
suggestionsConfig?.enabled,
]);
return (
<div

View File

@ -0,0 +1,20 @@
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
export interface SuggestionsConfigResponse {
enabled: boolean;
}
export async function loadSuggestionsConfig(): Promise<SuggestionsConfigResponse> {
const response = await fetch(`${getBackendBaseURL()}/api/suggestions/config`);
if (!response.ok) {
if (response.status === 404) {
// Fallback to true if the backend is older
return { enabled: true };
}
throw new Error(
`Failed to load suggestions config: ${response.statusText}`,
);
}
return response.json() as Promise<SuggestionsConfigResponse>;
}

View File

@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { loadSuggestionsConfig } from "./api";
export function useSuggestionsConfig() {
return useQuery({
queryKey: ["suggestionsConfig"],
queryFn: loadSuggestionsConfig,
staleTime: Infinity,
});
}

View File

@ -310,4 +310,47 @@ test.describe("Chat workspace", () => {
});
await expect(promptForm.getByText("report.docx")).toBeHidden();
});
test("does not fetch follow-up suggestions when disabled in config", async ({
page,
}) => {
await page.route("**/api/suggestions/config", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: false }),
});
});
let suggestionsFetched = false;
await page.route("**/api/threads/*/suggestions", (route) => {
suggestionsFetched = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ suggestions: [] }),
});
});
let streamCalled = false;
await page.route("**/runs/stream", (route) => {
streamCalled = true;
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Hello");
await textarea.press("Enter");
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await page.waitForTimeout(1000);
expect(suggestionsFetched).toBe(false);
});
});