mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-23 06:28:34 +00:00
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:
parent
525ec0a00d
commit
3e055d8f43
@ -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 |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
20
frontend/src/core/suggestions/api.ts
Normal file
20
frontend/src/core/suggestions/api.ts
Normal 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>;
|
||||
}
|
||||
11
frontend/src/core/suggestions/hooks.ts
Normal file
11
frontend/src/core/suggestions/hooks.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user