feat(suggestions): configure follow-up suggestion count (#4533)

This commit is contained in:
Ryker_Feng 2026-07-28 19:35:21 +08:00 committed by GitHub
parent 919caf7c83
commit 0a9ce5d7e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 57 additions and 7 deletions

View File

@ -8,6 +8,7 @@ import deerflow.utils.llm_text as llm_text
from app.gateway.authz import require_permission
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.config.suggestions_config import DEFAULT_MAX_SUGGESTIONS, MAX_SUGGESTIONS_LIMIT
from deerflow.utils.oneshot_llm import run_oneshot_llm
logger = logging.getLogger(__name__)
@ -22,7 +23,7 @@ class SuggestionMessage(BaseModel):
class SuggestionsRequest(BaseModel):
messages: list[SuggestionMessage] = Field(..., description="Recent conversation messages")
n: int = Field(default=3, ge=1, le=5, description="Number of suggestions to generate")
n: int = Field(default=DEFAULT_MAX_SUGGESTIONS, ge=1, le=MAX_SUGGESTIONS_LIMIT, description="Number of suggestions to generate")
model_name: str | None = Field(default=None, description="Optional model override")
@ -32,6 +33,7 @@ class SuggestionsResponse(BaseModel):
class SuggestionsConfigResponse(BaseModel):
enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally")
max_suggestions: int = Field(..., ge=1, le=MAX_SUGGESTIONS_LIMIT, description="Maximum number of follow-up suggestions to generate")
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence
@ -76,6 +78,10 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str:
return "\n".join(parts).strip()
def _configured_max_suggestions(config: AppConfig) -> int:
return getattr(config.suggestions, "max_suggestions", DEFAULT_MAX_SUGGESTIONS)
@router.get(
"/suggestions/config",
response_model=SuggestionsConfigResponse,
@ -85,7 +91,7 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str:
async def get_suggestions_config(
config: AppConfig = Depends(get_config),
) -> SuggestionsConfigResponse:
return SuggestionsConfigResponse(enabled=config.suggestions.enabled)
return SuggestionsConfigResponse(enabled=config.suggestions.enabled, max_suggestions=_configured_max_suggestions(config))
@router.post(
@ -106,7 +112,7 @@ async def generate_suggestions(
if not body.messages:
return SuggestionsResponse(suggestions=[])
n = body.n
n = min(body.n, _configured_max_suggestions(config))
conversation = _format_conversation(body.messages)
if not conversation:
return SuggestionsResponse(suggestions=[])

View File

@ -1,7 +1,16 @@
from pydantic import BaseModel, Field
DEFAULT_MAX_SUGGESTIONS = 3
MAX_SUGGESTIONS_LIMIT = 5
class SuggestionsConfig(BaseModel):
"""Configuration for automatic follow-up suggestions."""
enabled: bool = Field(default=True, description="Whether to enable follow-up question suggestions at the end of an AI response")
max_suggestions: int = Field(
default=DEFAULT_MAX_SUGGESTIONS,
ge=1,
le=MAX_SUGGESTIONS_LIMIT,
description="Maximum number of follow-up suggestions to generate.",
)

View File

@ -125,6 +125,31 @@ def test_generate_suggestions_parses_and_limits(monkeypatch):
assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "suggest_agent"}
def test_generate_suggestions_respects_configured_max(monkeypatch):
req = suggestions.SuggestionsRequest(
messages=[
suggestions.SuggestionMessage(role="user", content="Hi"),
suggestions.SuggestionMessage(role="assistant", content="Hello"),
],
n=4,
model_name=None,
)
fake_model = MagicMock()
fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='["Q1", "Q2", "Q3", "Q4"]'))
monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model)
result = asyncio.run(
suggestions.generate_suggestions.__wrapped__(
"t1",
req,
request=None,
config=SimpleNamespace(suggestions=SimpleNamespace(enabled=True, max_suggestions=2)),
)
)
assert result.suggestions == ["Q1", "Q2"]
def test_generate_suggestions_injects_deerflow_trace_metadata_when_langfuse_enabled(monkeypatch):
monkeypatch.setenv("LANGFUSE_TRACING", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
@ -248,8 +273,10 @@ def test_get_suggestions_config():
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
assert result_true.max_suggestions == 3
# Test when disabled
mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False))
mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False, max_suggestions=4))
result_false = asyncio.run(suggestions.get_suggestions_config(config=mock_config_false))
assert result_false.enabled is False
assert result_false.max_suggestions == 4

View File

@ -1050,6 +1050,7 @@ tool_output:
suggestions:
enabled: true
max_suggestions: 3
# ============================================================================

View File

@ -81,6 +81,7 @@ import {
type SidecarContext,
} from "@/core/sidecar";
import { useSkills } from "@/core/skills/hooks";
import { DEFAULT_MAX_SUGGESTIONS } from "@/core/suggestions/api";
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
import type { AgentThreadContext, GoalState } from "@/core/threads";
import { compactThreadContext } from "@/core/threads/api";
@ -398,6 +399,8 @@ export function InputBox({
const { data: suggestionsConfig } = useSuggestionsConfig();
const suggestionsConfigLoaded = suggestionsConfig !== undefined;
const suggestionsEnabled = suggestionsConfig?.enabled;
const maxFollowupSuggestions =
suggestionsConfig?.max_suggestions ?? DEFAULT_MAX_SUGGESTIONS;
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const [polishingInput, setPolishingInput] = useState(false);
@ -1998,7 +2001,7 @@ export function InputBox({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: recent,
n: 3,
n: maxFollowupSuggestions,
model_name: context.model_name ?? undefined,
}),
signal: controller.signal,
@ -2013,7 +2016,7 @@ export function InputBox({
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
.slice(0, 5);
.slice(0, maxFollowupSuggestions);
setFollowups(suggestions);
})
.catch(() => {
@ -2028,6 +2031,7 @@ export function InputBox({
context.model_name,
disabled,
isMock,
maxFollowupSuggestions,
status,
suggestionsConfigLoaded,
suggestionsEnabled,

View File

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