diff --git a/README.md b/README.md index dae57c915..0fb6176c8 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,8 @@ Tools follow the same philosophy. DeerFlow comes with a core toolset — web sea Gateway-generated follow-up suggestions now normalize both plain-string model output and block/list-style rich content before parsing the JSON array response, so provider-specific content wrappers do not silently drop suggestions. +The Web UI composer can polish draft input before sending. The rewrite runs as a short Gateway LLM request using the `input_polish` model configuration, keeps slash skill prefixes such as `/data-analysis`, and only replaces the local draft after the user clicks the polish button; it does not create a thread run or persist a message. + Interrupted first-turn runs still persist a fallback conversation title, so stopping a streaming response does not leave the thread as "Untitled" after refresh. In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 95b9a1cda..c43e6f853 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -315,6 +315,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline; `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; 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/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 (`...`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing | +| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) | | **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `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 /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `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 | diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index fd1c71b72..42ccaca33 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -22,6 +22,7 @@ from app.gateway.routers import ( features, feedback, github_webhooks, + input_polish, mcp, memory, models, @@ -362,6 +363,10 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for "name": "suggestions", "description": "Generate follow-up question suggestions for conversations", }, + { + "name": "input-polish", + "description": "Polish composer draft input before sending", + }, { "name": "channels", "description": "Manage IM channel integrations (Feishu, Slack, Telegram)", @@ -445,6 +450,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for # Suggestions API is mounted at /api/threads/{thread_id}/suggestions app.include_router(suggestions.router) + # Input polishing API is mounted at /api/input-polish + app.include_router(input_polish.router) + # User-facing IM channel connection API is mounted at /api/channels app.include_router(channel_connections.router) diff --git a/backend/app/gateway/routers/__init__.py b/backend/app/gateway/routers/__init__.py index b271a1228..e1750469d 100644 --- a/backend/app/gateway/routers/__init__.py +++ b/backend/app/gateway/routers/__init__.py @@ -1,6 +1,7 @@ from . import ( artifacts, assistants_compat, + input_polish, mcp, models, scheduled_tasks, @@ -14,6 +15,7 @@ from . import ( __all__ = [ "artifacts", "assistants_compat", + "input_polish", "mcp", "models", "scheduled_tasks", diff --git a/backend/app/gateway/routers/input_polish.py b/backend/app/gateway/routers/input_polish.py new file mode 100644 index 000000000..257b529d4 --- /dev/null +++ b/backend/app/gateway/routers/input_polish.py @@ -0,0 +1,107 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +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.utils.oneshot_llm import run_oneshot_llm + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["input-polish"]) + + +class InputPolishRequest(BaseModel): + text: str = Field(..., description="Draft text currently shown in the composer") + locale: str | None = Field(default=None, description="Optional UI locale hint") + thread_id: str | None = Field(default=None, description="Optional thread id for tracing only") + + +class InputPolishResponse(BaseModel): + rewritten_text: str = Field(..., description="Polished draft text") + changed: bool = Field(..., description="Whether the model changed the original draft") + + +def _clean_rewritten_text(text: str) -> str: + # The polished draft may legitimately contain a literal "" substring + # (e.g. a draft that asks about the tag), so do NOT truncate at a dangling + # open tag here — that would silently drop the rest of a valid rewrite and + # can produce a spurious 503. Complete ... blocks are still + # removed. + candidate = llm_text.strip_think_blocks(text, truncate_unclosed=False) + candidate = llm_text.strip_markdown_code_fence(candidate) + return candidate.strip() + + +def _build_system_instruction() -> str: + return ( + "You are DeerFlow's pre-send prompt optimizer.\n" + "Rewrite the user's rough draft into a clearer instruction for an AI agent before it is sent.\n" + "Do not answer the task.\n" + "Preserve the user's language, intent, entities, file paths, URLs, code blocks, and any leading slash command prefix exactly.\n" + "Improve the draft by making the goal, scope, constraints, and desired output explicit when they are implied by the draft.\n" + "For vague quality words such as 'better', 'good-looking', or 'polished', translate them into concrete but generic quality criteria.\n" + "Do not invent facts, business context, tools, file names, dates, metrics, or user preferences that are not implied.\n" + "Prefer one concise paragraph or a short bullet list. Keep it under 180 words unless the original draft is longer.\n" + "Output only the rewritten draft, with no markdown wrapper, explanation, or alternatives." + ) + + +def _build_user_content(text: str, locale: str | None) -> str: + locale_hint = locale.strip() if locale else "same language as the draft" + return f"Locale hint: {locale_hint}\n\nRewrite this draft while preserving its intent:\n\n{text}\n" + + +@router.post( + "/input-polish", + response_model=InputPolishResponse, + summary="Polish Composer Input", + description="Rewrite a draft message before it is sent. This does not create a thread run or persist any message.", +) +@require_permission("runs", "create") +async def polish_input( + body: InputPolishRequest, + request: Request, + config: AppConfig = Depends(get_config), +) -> InputPolishResponse: + del request # Required by the auth decorator. + + if not config.input_polish.enabled: + raise HTTPException(status_code=404, detail="Input polishing is disabled") + + # Validate the same normalized view of the input that we send to the model, + # so the user-facing length boundary and the model input cannot disagree + # (e.g. a padded draft passing the check but arriving with stray whitespace). + text = body.text.strip() + if not text: + raise HTTPException(status_code=400, detail="Input text is required") + + max_chars = config.input_polish.max_chars + if len(text) > max_chars: + raise HTTPException(status_code=400, detail=f"Input text exceeds {max_chars} characters") + + model_name = config.input_polish.model_name + try: + raw = await run_oneshot_llm( + system_instruction=_build_system_instruction(), + user_content=_build_user_content(text, body.locale), + run_name="input_polish", + app_config=config, + model_name=model_name, + thread_id=body.thread_id, + ) + rewritten = _clean_rewritten_text(raw) + except Exception as exc: + logger.exception("Failed to polish input: thread_id=%s err=%s", body.thread_id, exc) + raise HTTPException(status_code=503, detail="Failed to polish input") from exc + + if not rewritten: + raise HTTPException(status_code=503, detail="Failed to polish input") + + return InputPolishResponse( + rewritten_text=rewritten, + changed=rewritten != text, + ) diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index c672ce5b2..ce001e24a 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -1,18 +1,14 @@ import json import logging -import os from fastapi import APIRouter, Depends, Request -from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field 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.models import create_chat_model -from deerflow.runtime.user_context import get_effective_user_id -from deerflow.tracing import inject_langfuse_metadata +from deerflow.utils.oneshot_llm import run_oneshot_llm logger = logging.getLogger(__name__) @@ -38,7 +34,6 @@ class SuggestionsConfigResponse(BaseModel): enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally") -_extract_response_text = llm_text.extract_response_text _strip_markdown_code_fence = llm_text.strip_markdown_code_fence _strip_think_blocks = llm_text.strip_think_blocks @@ -129,18 +124,14 @@ async def generate_suggestions( user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions" try: - model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config) - invoke_config: dict = {"run_name": "suggest_agent"} - inject_langfuse_metadata( - invoke_config, - thread_id=thread_id, - user_id=get_effective_user_id(), - assistant_id="suggest_agent", + raw = await run_oneshot_llm( + system_instruction=system_instruction, + user_content=user_content, + run_name="suggest_agent", + app_config=config, model_name=body.model_name, - environment=os.environ.get("DEER_FLOW_ENV") or os.environ.get("ENVIRONMENT"), + thread_id=thread_id, ) - response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)], config=invoke_config) - raw = _extract_response_text(response.content) suggestions = _parse_json_string_list(raw) or [] cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()] cleaned = cleaned[:n] diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 98cab4b47..1b53fd30e 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -18,6 +18,7 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict +from deerflow.config.input_polish_config import InputPolishConfig from deerflow.config.loop_detection_config import LoopDetectionConfig from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.model_config import ModelConfig @@ -167,6 +168,7 @@ class AppConfig(BaseModel): acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP-compatible agent configuration") subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration") guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration") + input_polish: InputPolishConfig = Field(default_factory=InputPolishConfig, description="Pre-send input polishing configuration.") suggestions: SuggestionsConfig = Field(default_factory=SuggestionsConfig, description="Follow-up suggestions configuration.") circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration") channel_connections: ChannelConnectionsConfig = Field( diff --git a/backend/packages/harness/deerflow/config/input_polish_config.py b/backend/packages/harness/deerflow/config/input_polish_config.py new file mode 100644 index 000000000..c2745ac8c --- /dev/null +++ b/backend/packages/harness/deerflow/config/input_polish_config.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, Field + + +class InputPolishConfig(BaseModel): + """Configuration for pre-send input polishing.""" + + enabled: bool = Field(default=True, description="Whether to enable pre-send input polishing in the composer") + max_chars: int = Field(default=4000, ge=1, description="Maximum number of draft characters accepted by the input polishing endpoint") + model_name: str | None = Field(default=None, description="Optional model name override for input polishing") diff --git a/backend/packages/harness/deerflow/utils/llm_text.py b/backend/packages/harness/deerflow/utils/llm_text.py index bd792a3c0..625cb41aa 100644 --- a/backend/packages/harness/deerflow/utils/llm_text.py +++ b/backend/packages/harness/deerflow/utils/llm_text.py @@ -10,12 +10,23 @@ _THINK_BLOCK_RE = re.compile(r"]*>.*?", re.IGNORECASE | re _OPEN_THINK_RE = re.compile(r"]*>", re.IGNORECASE) -def strip_think_blocks(text: str) -> str: - """Remove inline reasoning ```` blocks from a model response.""" +def strip_think_blocks(text: str, *, truncate_unclosed: bool = True) -> str: + """Remove inline reasoning ```` blocks from a model response. + + Complete ``...`` blocks are always removed. A dangling, + unclosed ```` open tag is treated as a model that was truncated + mid-thought: when ``truncate_unclosed`` is True (the default, used by JSON + parsers like suggestions/goal where trailing garbage must be dropped) the + text is cut at that tag. Callers that may legitimately echo a literal + ```` substring in their output (e.g. the input polisher rewriting a + draft that mentions the tag) pass ``truncate_unclosed=False`` so the tag is + preserved instead of silently discarding the rest of the text. + """ text = _THINK_BLOCK_RE.sub("", text) - open_match = _OPEN_THINK_RE.search(text) - if open_match: - text = text[: open_match.start()] + if truncate_unclosed: + open_match = _OPEN_THINK_RE.search(text) + if open_match: + text = text[: open_match.start()] return text.strip() diff --git a/backend/packages/harness/deerflow/utils/oneshot_llm.py b/backend/packages/harness/deerflow/utils/oneshot_llm.py new file mode 100644 index 000000000..c9582116a --- /dev/null +++ b/backend/packages/harness/deerflow/utils/oneshot_llm.py @@ -0,0 +1,72 @@ +"""Shared helper for one-shot, non-graph LLM text requests. + +Several Gateway routes (input polishing, follow-up suggestions, and title-style +rewrites) do the same thing: build a chat model from config, attach Langfuse +trace metadata, invoke it once with a system + user message pair, and pull the +plain text back out of the response. Centralizing that sequence here keeps the +tracing-metadata fields and invocation shape from drifting between routers — a +fix to one (e.g. a new Langfuse field) now applies to all callers instead of +silently regressing in whichever copy was forgotten. + +Response-text *cleaning* (think-block / code-fence stripping, JSON parsing) is +intentionally left to each caller because their post-processing differs; this +helper stops at the extracted raw text. +""" + +from __future__ import annotations + +import os + +from langchain_core.messages import HumanMessage, SystemMessage + +from deerflow.config.app_config import AppConfig +from deerflow.models import create_chat_model +from deerflow.runtime.user_context import get_effective_user_id +from deerflow.tracing import inject_langfuse_metadata +from deerflow.utils.llm_text import extract_response_text + + +def _resolve_environment() -> str | None: + return os.environ.get("DEER_FLOW_ENV") or os.environ.get("ENVIRONMENT") + + +async def run_oneshot_llm( + *, + system_instruction: str, + user_content: str, + run_name: str, + app_config: AppConfig, + model_name: str | None = None, + thread_id: str | None = None, +) -> str: + """Run a single non-graph system+user LLM turn and return the raw text. + + Args: + system_instruction: System message content. + user_content: Human message content. + run_name: LangChain ``run_name`` and Langfuse ``assistant_id`` for the call. + app_config: Application config used to build the model. + model_name: Optional model override; ``None`` uses the default model. + thread_id: Optional thread id, forwarded to Langfuse for tracing only. + + Returns: + The extracted plain-text content of the model response (uncleaned). + """ + model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config) + invoke_config: dict = {"run_name": run_name} + inject_langfuse_metadata( + invoke_config, + thread_id=thread_id, + user_id=get_effective_user_id(), + assistant_id=run_name, + model_name=model_name, + environment=_resolve_environment(), + ) + response = await model.ainvoke( + [ + SystemMessage(content=system_instruction), + HumanMessage(content=user_content), + ], + config=invoke_config, + ) + return extract_response_text(response.content) diff --git a/backend/tests/test_input_polish_router.py b/backend/tests/test_input_polish_router.py new file mode 100644 index 000000000..fe22a070b --- /dev/null +++ b/backend/tests/test_input_polish_router.py @@ -0,0 +1,195 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from app.gateway.routers import input_polish +from deerflow.utils import oneshot_llm + + +def _config( + *, + enabled: bool = True, + max_chars: int = 4000, + model_name: str | None = None, +): + return SimpleNamespace( + input_polish=SimpleNamespace( + enabled=enabled, + max_chars=max_chars, + model_name=model_name, + ), + ) + + +def test_clean_rewritten_text_removes_think_and_fence(): + text = "reasoning\n```text\nrewrite this\n```" + assert input_polish._clean_rewritten_text(text) == "rewrite this" + + +def test_clean_rewritten_text_keeps_literal_think_tag(): + # A polished draft may legitimately mention the tag. The cleaner + # must not truncate at the dangling open tag (which would drop the rest of + # the rewrite and can surface as a spurious 503). + text = "Explain what the tag does in reasoning models." + assert input_polish._clean_rewritten_text(text) == "Explain what the tag does in reasoning models." + + +def test_polish_input_uses_config_model_and_preserves_response(monkeypatch): + request = input_polish.InputPolishRequest( + text="/web-dev 做一个页面", + locale="zh-CN", + thread_id="thread-1", + ) + fake_model = MagicMock() + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content="/web-dev 请设计并实现一个视觉精致的页面。")) + + create_chat_model = MagicMock(return_value=fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", create_chat_model) + config = _config(model_name="polish-model") + + result = asyncio.run( + input_polish.polish_input.__wrapped__( + request, + request=None, + config=config, + ), + ) + + assert result.rewritten_text == "/web-dev 请设计并实现一个视觉精致的页面。" + assert result.changed is True + create_chat_model.assert_called_once_with( + name="polish-model", + thinking_enabled=False, + app_config=config, + ) + fake_model.ainvoke.assert_awaited_once() + assert fake_model.ainvoke.await_args.kwargs["config"]["run_name"] == "input_polish" + + +def test_polish_input_uses_default_model_when_config_model_is_missing(monkeypatch): + request = input_polish.InputPolishRequest(text="make this clearer") + fake_model = MagicMock() + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content="Make this clearer.")) + + create_chat_model = MagicMock(return_value=fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", create_chat_model) + + result = asyncio.run( + input_polish.polish_input.__wrapped__( + request, + request=None, + config=_config(model_name=None), + ), + ) + + assert result.rewritten_text == "Make this clearer." + create_chat_model.assert_called_once() + assert create_chat_model.call_args.kwargs["name"] is None + + +def test_polish_input_returns_404_when_disabled(monkeypatch): + request = input_polish.InputPolishRequest(text="hello") + fake_model = MagicMock() + monkeypatch.setattr(oneshot_llm, "create_chat_model", fake_model) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + input_polish.polish_input.__wrapped__( + request, + request=None, + config=_config(enabled=False), + ), + ) + + assert exc_info.value.status_code == 404 + fake_model.assert_not_called() + + +def test_polish_input_rejects_empty_or_too_long_input(monkeypatch): + fake_model = MagicMock() + monkeypatch.setattr(oneshot_llm, "create_chat_model", fake_model) + + with pytest.raises(HTTPException) as empty_exc: + asyncio.run( + input_polish.polish_input.__wrapped__( + input_polish.InputPolishRequest(text=" "), + request=None, + config=_config(), + ), + ) + assert empty_exc.value.status_code == 400 + + with pytest.raises(HTTPException) as long_exc: + asyncio.run( + input_polish.polish_input.__wrapped__( + input_polish.InputPolishRequest(text="hello"), + request=None, + config=_config(max_chars=4), + ), + ) + assert long_exc.value.status_code == 400 + fake_model.assert_not_called() + + +def test_polish_input_returns_503_on_model_error(monkeypatch): + request = input_polish.InputPolishRequest(text="hello") + fake_model = MagicMock() + fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("boom")) + monkeypatch.setattr(oneshot_llm, "create_chat_model", MagicMock(return_value=fake_model)) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + input_polish.polish_input.__wrapped__( + request, + request=None, + config=_config(), + ), + ) + + assert exc_info.value.status_code == 503 + + +def test_polish_input_rejects_whitespace_only_draft(monkeypatch): + # A padded draft that is empty after normalization is rejected as empty, + # matching the normalized view used for the model input. + fake_model = MagicMock() + monkeypatch.setattr(oneshot_llm, "create_chat_model", fake_model) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + input_polish.polish_input.__wrapped__( + input_polish.InputPolishRequest(text=" \n\t "), + request=None, + config=_config(), + ), + ) + + assert exc_info.value.status_code == 400 + fake_model.assert_not_called() + + +def test_polish_input_validates_and_sends_normalized_text(monkeypatch): + # The length boundary and the model input must agree on one normalized view: + # a draft whose raw length exceeds max_chars only due to padding is accepted + # (strip fits), and the model receives the stripped text, not the padding. + raw_draft = " summarize report " # 22 chars raw, 16 chars stripped + fake_model = MagicMock() + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content="Please summarize the report clearly.")) + monkeypatch.setattr(oneshot_llm, "create_chat_model", MagicMock(return_value=fake_model)) + + result = asyncio.run( + input_polish.polish_input.__wrapped__( + input_polish.InputPolishRequest(text=raw_draft), + request=None, + config=_config(max_chars=len(raw_draft.strip())), + ), + ) + + assert result.rewritten_text == "Please summarize the report clearly." + messages = fake_model.ainvoke.await_args.args[0] + human_content = messages[-1].content + assert "summarize report" in human_content + assert " summarize report " not in human_content diff --git a/backend/tests/test_suggestions_router.py b/backend/tests/test_suggestions_router.py index dcb6bbb8f..b50bae597 100644 --- a/backend/tests/test_suggestions_router.py +++ b/backend/tests/test_suggestions_router.py @@ -6,6 +6,7 @@ import pytest from app.gateway.routers import suggestions from deerflow.trace_context import request_trace_context +from deerflow.utils import oneshot_llm @pytest.fixture(autouse=True) @@ -86,7 +87,7 @@ def test_generate_suggestions_strips_inline_think_block(monkeypatch): content = '\nThe user asked about deep learning. Options: maybe [1] frameworks, [2] math basics.\n\n["深度学习和机器学习的区别?", "常用框架有哪些?", "需要什么数学基础?"]' fake_model = MagicMock() fake_model.ainvoke = AsyncMock(return_value=MagicMock(content=content)) - monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) + 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)))) @@ -113,7 +114,7 @@ def test_generate_suggestions_parses_and_limits(monkeypatch): ) fake_model = MagicMock() fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='```json\n["Q1", "Q2", "Q3", "Q4"]\n```')) - monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model) # Bypass the require_permission decorator (which needs request + # thread_store) — these tests cover the parsing logic. @@ -141,7 +142,7 @@ def test_generate_suggestions_injects_deerflow_trace_metadata_when_langfuse_enab ) fake_model = MagicMock() fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='["Q1"]')) - monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model) try: with request_trace_context("suggest-trace-1"): @@ -167,7 +168,7 @@ def test_generate_suggestions_parses_list_block_content(monkeypatch): ) fake_model = MagicMock() fake_model.ainvoke = AsyncMock(return_value=MagicMock(content=[{"type": "text", "text": '```json\n["Q1", "Q2"]\n```'}])) - monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model) # Bypass the require_permission decorator (which needs request + # thread_store) — these tests cover the parsing logic. @@ -189,7 +190,7 @@ def test_generate_suggestions_parses_output_text_block_content(monkeypatch): ) fake_model = MagicMock() fake_model.ainvoke = AsyncMock(return_value=MagicMock(content=[{"type": "output_text", "text": '```json\n["Q1", "Q2"]\n```'}])) - monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model) # Bypass the require_permission decorator (which needs request + # thread_store) — these tests cover the parsing logic. @@ -208,7 +209,7 @@ def test_generate_suggestions_returns_empty_on_model_error(monkeypatch): ) fake_model = MagicMock() fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("boom")) - monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model) # Bypass the require_permission decorator (which needs request + # thread_store) — these tests cover the parsing logic. @@ -232,7 +233,7 @@ def test_generate_suggestions_returns_empty_when_disabled(monkeypatch): fake_model = MagicMock() fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("Model should not be called.")) - monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) + monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model) result = asyncio.run(suggestions.generate_suggestions.__wrapped__("t1", req, request=None, config=mock_config)) diff --git a/config.example.yaml b/config.example.yaml index 400745f76..7240989a1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,7 +15,7 @@ # ============================================================================ # Bump this number when the config schema changes. # Run `make config-upgrade` to merge new fields into your local config.yaml. -config_version: 19 +config_version: 20 # ============================================================================ # Logging @@ -911,6 +911,20 @@ suggestions: enabled: true +# ============================================================================ +# Input Polish Configuration +# ============================================================================ +# Configure whether the composer can rewrite draft input before sending. + +input_polish: + enabled: true + # Maximum draft length accepted by /api/input-polish. + max_chars: 4000 + # Optional fast model for draft polishing. Leave null to use the default chat model. + # For best UX, set this to your lowest-latency inexpensive model. + model_name: null + + # ============================================================================ # Loop Detection Configuration # ============================================================================ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 76d5024ee..8bb6ea196 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -53,7 +53,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat - `workspace/` — Chat page components (messages, artifacts, settings) - `landing/` — Landing page sections - `docs/` — Docs / MDX rendering components -- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`. +- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`. - **`hooks/`** — Shared React hooks - **`lib/`** — Utilities (`cn()` from clsx + tailwind-merge) - **`content/`** — MDX content (blog posts, docs) rendered by the app @@ -63,7 +63,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat ### Data Flow -1. User input → thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming +1. Optional composer helpers such as `core/input-polish` can rewrite the local draft before submission; confirmed user input then flows to thread hooks (`core/threads/hooks.ts`) → LangGraph SDK streaming 2. Stream events update thread state (messages, artifacts, todos, goal) 3. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits 4. TanStack Query manages server state; localStorage stores user settings diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index fd40848f7..0dbadbb11 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -189,6 +189,17 @@ export function parseCompactCommand(value: string): boolean { return /^\/(?:compact|context\s+compact)\s*$/i.test(value.trim()); } +export function canPolishInput(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + // Reserved builtin command lines are routed to their own handlers, not the + // LLM, so they must not be rewritten. Reuse the same parsers the composer + // uses to dispatch them instead of maintaining a third parallel list. + return parseGoalCommand(trimmed) === null && !parseCompactCommand(trimmed); +} + export function getInputSubmitAction({ text, fileCount, diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index b4523a763..a000663cc 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -7,11 +7,13 @@ import { CheckIcon, GraduationCapIcon, LightbulbIcon, + Loader2Icon, PaperclipIcon, PlusIcon, - SparklesIcon, RocketIcon, + SparklesIcon, TargetIcon, + Undo2Icon, XIcon, ZapIcon, } from "lucide-react"; @@ -64,6 +66,8 @@ import { import { fetch } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; +import { polishInputDraft } from "@/core/input-polish/api"; +import { hasOpenHumanInputRequest } from "@/core/messages/human-input"; import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; import { @@ -106,6 +110,7 @@ import { import { abortGoalRequest, beginGoalRequest, + canPolishInput, createGoalRequestState, findSuggestionTemplatePlaceholder, finishGoalRequest, @@ -253,7 +258,7 @@ export function InputBox({ ) => void | Promise; onStop?: () => void; }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const queryClient = useQueryClient(); const searchParams = useSearchParams(); const [modelDialogOpen, setModelDialogOpen] = useState(false); @@ -269,6 +274,13 @@ export function InputBox({ const textareaRef = useRef(null); const goalRequestStateRef = useRef(createGoalRequestState()); const compactRequestStateRef = useRef(createGoalRequestState()); + const inputPolishRequestRef = useRef<{ + controller: AbortController | null; + sequence: number; + }>({ + controller: null, + sequence: 0, + }); const promptHistoryIndexRef = useRef(null); const promptHistoryDraftRef = useRef(""); @@ -278,6 +290,11 @@ export function InputBox({ const suggestionsEnabled = suggestionsConfig?.enabled; const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false); + const [polishingInput, setPolishingInput] = useState(false); + const [inputPolishUndo, setInputPolishUndo] = useState<{ + originalText: string; + rewrittenText: string; + } | null>(null); const [textareaFocused, setTextareaFocused] = useState(false); const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0); const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] = @@ -434,6 +451,7 @@ export function InputBox({ useEffect(() => { promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; + setInputPolishUndo(null); }, [threadId]); useEffect(() => { @@ -445,6 +463,17 @@ export function InputBox({ }; }, [threadId]); + const abortInputPolishRequest = useCallback(() => { + inputPolishRequestRef.current.controller?.abort(); + inputPolishRequestRef.current.controller = null; + inputPolishRequestRef.current.sequence += 1; + setPolishingInput(false); + }, []); + + useEffect(() => { + return () => abortInputPolishRequest(); + }, [abortInputPolishRequest, threadId]); + useEffect(() => { const currentIndex = promptHistoryIndexRef.current; if (currentIndex !== null && currentIndex >= promptHistory.length) { @@ -455,6 +484,9 @@ export function InputBox({ const handleModelSelect = useCallback( (model_name: string) => { + if (disabled || polishingInput) { + return; + } const model = models.find((m) => m.name === model_name); if (!model) { return; @@ -467,11 +499,14 @@ export function InputBox({ }); setModelDialogOpen(false); }, - [onContextChange, context, models], + [disabled, onContextChange, context, models, polishingInput], ); const handleModeSelect = useCallback( (mode: InputMode) => { + if (disabled || polishingInput) { + return; + } onContextChange?.({ ...context, mode: getResolvedMode(mode, supportThinking), @@ -485,17 +520,20 @@ export function InputBox({ : "minimal", }); }, - [onContextChange, context, supportThinking], + [disabled, onContextChange, context, polishingInput, supportThinking], ); const handleReasoningEffortSelect = useCallback( (effort: "minimal" | "low" | "medium" | "high") => { + if (disabled || polishingInput) { + return; + } onContextChange?.({ ...context, reasoning_effort: effort, }); }, - [onContextChange, context], + [disabled, onContextChange, context, polishingInput], ); const handleGoalCommand = useCallback( @@ -702,6 +740,7 @@ export function InputBox({ } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; + setInputPolishUndo(null); setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); @@ -881,6 +920,30 @@ export function InputBox({ slashSkillQuery !== null && skillSuggestions.length > 0 && dismissedSkillSuggestionValue !== textInput.value; + const isComposerDisabled = disabled === true; + const isMockThread = isMock === true; + const hasOpenHumanInputCard = useMemo( + () => + hasOpenHumanInputRequest( + thread.messages, + (message) => !isHiddenFromUIMessage(message), + ), + [thread.messages], + ); + const composerLocked = isComposerDisabled || polishingInput; + const inputPolishUndoAvailable = + !polishingInput && + inputPolishUndo !== null && + (textInput.value ?? "") === inputPolishUndo.rewrittenText; + const inputPolishDisabled = + isComposerDisabled || + isMockThread || + hasOpenHumanInputCard || + polishingInput || + (!inputPolishUndoAvailable && + (status === "streaming" || + slashSkillQuery !== null || + !canPolishInput(textInput.value ?? ""))); useEffect(() => { setSkillSuggestionIndex(0); @@ -967,6 +1030,94 @@ export function InputBox({ [textInput], ); + const handlePolishInput = useCallback(async () => { + if (inputPolishDisabled) { + return; + } + + const originalText = textInput.value ?? ""; + const controller = new AbortController(); + inputPolishRequestRef.current.controller?.abort(); + const sequence = inputPolishRequestRef.current.sequence + 1; + inputPolishRequestRef.current = { + controller, + sequence, + }; + setPolishingInput(true); + + try { + const result = await polishInputDraft( + { + text: originalText, + locale, + thread_id: threadId, + }, + { signal: controller.signal }, + ); + + const isCurrentRequest = + inputPolishRequestRef.current.controller === controller && + inputPolishRequestRef.current.sequence === sequence && + !controller.signal.aborted; + if (!isCurrentRequest || (textInput.value ?? "") !== originalText) { + return; + } + + const rewrittenText = result.rewritten_text.trim(); + if (!rewrittenText || !result.changed) { + toast.info(t.inputBox.inputPolishNoChanges); + return; + } + + // Applying the rewrite replaces the draft outside the textarea change + // handler, so clear any in-progress history browse state; otherwise a + // stale index would let the next ArrowDown overwrite the rewrite. + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; + setPromptHistoryValue(rewrittenText); + setInputPolishUndo({ + originalText, + rewrittenText, + }); + } catch (error) { + const isCurrentRequest = + inputPolishRequestRef.current.controller === controller && + inputPolishRequestRef.current.sequence === sequence; + if (isAbortError(error) || !isCurrentRequest) { + return; + } + toast.error( + error instanceof Error ? error.message : t.inputBox.inputPolishFailed, + ); + } finally { + if ( + inputPolishRequestRef.current.controller === controller && + inputPolishRequestRef.current.sequence === sequence + ) { + inputPolishRequestRef.current.controller = null; + setPolishingInput(false); + } + } + }, [ + inputPolishDisabled, + locale, + setPromptHistoryValue, + t.inputBox.inputPolishFailed, + t.inputBox.inputPolishNoChanges, + textInput, + threadId, + ]); + + const handleUndoInputPolish = useCallback(() => { + if (!inputPolishUndoAvailable || inputPolishUndo === null) { + return; + } + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; + setPromptHistoryValue(inputPolishUndo.originalText); + setInputPolishUndo(null); + }, [inputPolishUndo, inputPolishUndoAvailable, setPromptHistoryValue]); + const handlePromptHistoryKeyDown = useCallback( (event: KeyboardEvent) => { if ( @@ -1033,9 +1184,11 @@ export function InputBox({ ); const handlePromptTextareaChange = useCallback(() => { + abortInputPolishRequest(); + setInputPolishUndo(null); promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; - }, []); + }, [abortInputPolishRequest]); const showFollowups = !disabled && @@ -1247,14 +1400,22 @@ export function InputBox({ + {polishingInput && ( +