feat: add composer input polishing (#3986)

* feat: add composer input polishing

* Revert "Merge branch 'main' into feat/input-polish"

This reverts commit 5b6ceccf0db3092bc62fde3b05e7816829601756, reversing
changes made to 45fbc57fef5fa5fd878cf0176c37f3e3bc7ebef6.

* Merge main into feat/input-polish

* style(frontend): format input helper polish guard

* fix(input-polish): address composer polish review findings

Frontend
- Add a cancel affordance to the in-flight polish status pill that calls
  abortInputPolishRequest(), so a slow/hung provider no longer hard-locks the
  composer for up to stream_chunk_timeout with a page reload (and draft loss)
  as the only escape.
- Reset promptHistoryIndexRef/promptHistoryDraftRef when a rewrite is applied
  (and on undo), so a stale history-browse index can no longer let the next
  ArrowDown silently overwrite the polished draft.
- Disable polishing while an open human-input card is present, matching the
  frontend/AGENTS.md rule that composer entry points defer to the card so
  card-reply metadata is preserved.
- canPolishInput now reuses parseGoalCommand/parseCompactCommand instead of a
  third hardcoded reserved-command regex, and drops the phantom /help entry
  (no /help parser exists in the composer), so future builtins only need to be
  taught to the existing parsers.

Backend
- Extract the non-graph one-shot LLM path (build model + inject Langfuse
  metadata + system/user invoke + text extract) into
  deerflow.utils.oneshot_llm.run_oneshot_llm, shared by the input-polish and
  suggestions routers so tracing-metadata and invocation shape cannot drift
  between the two copies.
- strip_think_blocks gains truncate_unclosed (default True, preserving the
  suggestions/goal JSON-prep behavior); input polish passes False so a draft
  that legitimately contains a literal <think> substring is no longer
  truncated into a partial rewrite or a spurious 503.
- Validate the empty-check and max_chars boundary against the same stripped
  view of the draft that is sent to the model, so the user-facing length
  boundary and the model input can no longer disagree.

Tests / docs
- Backend: literal-<think> preservation, whitespace-only rejection, and
  normalized-length/model-input agreement cases; suggestions tests repoint the
  create_chat_model patch to the shared helper module.
- Frontend: helper unit tests updated for the /help/reserved-command change; a
  new Playwright case covers cancelling an in-flight polish request.
- backend/AGENTS.md documents the shared one-shot helper and the polish
  normalization/think-tag behavior.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Ryker_Feng 2026-07-08 17:10:27 +08:00 committed by GitHub
parent e3137b13cf
commit 01dc067997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 925 additions and 43 deletions

View file

@ -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.

View file

@ -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 (`<think>...</think>`, 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 `<think>` 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 |

View file

@ -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)

View file

@ -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",

View file

@ -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 "<think>" 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 <think>...</think> 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<draft>\n{text}\n</draft>"
@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,
)

View file

@ -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]

View file

@ -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(

View file

@ -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")

View file

@ -10,12 +10,23 @@ _THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
def strip_think_blocks(text: str) -> str:
"""Remove inline reasoning ``<think>`` blocks from a model response."""
def strip_think_blocks(text: str, *, truncate_unclosed: bool = True) -> str:
"""Remove inline reasoning ``<think>`` blocks from a model response.
Complete ``<think>...</think>`` blocks are always removed. A dangling,
unclosed ``<think>`` 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
``<think>`` 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()

View file

@ -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)

View file

@ -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 = "<think>reasoning</think>\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 <think> 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 <think> tag does in reasoning models."
assert input_polish._clean_rewritten_text(text) == "Explain what the <think> 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

View file

@ -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 = '<think>\nThe user asked about deep learning. Options: maybe [1] frameworks, [2] math basics.\n</think>\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))

View file

@ -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
# ============================================================================

View file

@ -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

View file

@ -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,

View file

@ -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<void>;
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<HTMLTextAreaElement | null>(null);
const goalRequestStateRef = useRef(createGoalRequestState());
const compactRequestStateRef = useRef(createGoalRequestState());
const inputPolishRequestRef = useRef<{
controller: AbortController | null;
sequence: number;
}>({
controller: null,
sequence: 0,
});
const promptHistoryIndexRef = useRef<number | null>(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<HTMLTextAreaElement>) => {
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({
<PromptInput
className={cn(
"bg-background/85 relative z-10 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
polishingInput &&
"shadow-primary/10 ring-primary/25 shadow-lg ring-1",
className,
)}
disabled={disabled}
disabled={composerLocked}
globalDrop
multiple
onSubmit={handleSubmit}
{...props}
>
{polishingInput && (
<div
aria-hidden="true"
className="border-primary/30 bg-primary/5 pointer-events-auto absolute inset-0 z-20 animate-pulse cursor-wait rounded-2xl border opacity-80"
/>
)}
{extraHeader && (
<div className="absolute top-0 right-0 left-0 z-10">
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center">
@ -1270,6 +1431,25 @@ export function InputBox({
</div>
)}
</PromptInputAttachments>
{polishingInput && (
<div
aria-live="polite"
className="text-primary bg-primary/10 border-primary/20 relative z-30 flex h-7 items-center gap-1.5 rounded-full border py-0 pr-1 pl-2.5 text-xs font-medium"
role="status"
>
<Loader2Icon className="size-3 animate-spin" />
{t.inputBox.inputPolishing}
<button
aria-label={t.inputBox.inputPolishCancel}
className="hover:bg-primary/20 focus-visible:ring-primary/40 -mr-0.5 ml-0.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
data-testid="cancel-polish-input-button"
onClick={abortInputPolishRequest}
type="button"
>
<XIcon className="size-3" />
</button>
</div>
)}
{sidecar && sidecar.conversationQuotes.length > 0 && (
<ReferenceAttachmentSummary
references={sidecar.conversationQuotes}
@ -1281,7 +1461,7 @@ export function InputBox({
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
<PromptInputTextarea
className={cn("size-full")}
disabled={disabled}
disabled={composerLocked}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
@ -1305,8 +1485,42 @@ export function InputBox({
</PromptInputActionMenu> */}
<AddAttachmentsButton
className="px-2!"
disabled={composerLocked}
uploadLimits={uploadLimits}
/>
<Tooltip
content={
polishingInput
? t.inputBox.inputPolishing
: inputPolishUndoAvailable
? t.inputBox.inputPolishUndo
: t.inputBox.inputPolish
}
>
<PromptInputButton
aria-label={
inputPolishUndoAvailable
? t.inputBox.inputPolishUndo
: t.inputBox.inputPolish
}
className="px-2!"
data-testid="polish-input-button"
disabled={inputPolishDisabled}
onClick={
inputPolishUndoAvailable
? handleUndoInputPolish
: handlePolishInput
}
>
{polishingInput ? (
<Loader2Icon className="size-3 animate-spin" />
) : inputPolishUndoAvailable ? (
<Undo2Icon className="size-3" />
) : (
<SparklesIcon className="size-3" />
)}
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@ -1318,7 +1532,10 @@ export function InputBox({
: "flash"
}
>
<PromptInputActionMenuTrigger className="max-w-28 gap-1! px-2! sm:max-w-none">
<PromptInputActionMenuTrigger
className="max-w-28 gap-1! px-2! sm:max-w-none"
disabled={composerLocked}
>
<div>
{context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && (
@ -1480,7 +1697,10 @@ export function InputBox({
</PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="hidden gap-1! px-2! sm:inline-flex">
<PromptInputActionMenuTrigger
className="hidden gap-1! px-2! sm:inline-flex"
disabled={composerLocked}
>
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
@ -1601,7 +1821,10 @@ export function InputBox({
onOpenChange={setModelDialogOpen}
>
<ModelSelectorTrigger asChild>
<PromptInputButton className="max-w-40 min-w-0 sm:max-w-56">
<PromptInputButton
className="max-w-40 min-w-0 sm:max-w-56"
disabled={composerLocked}
>
<div className="flex min-w-0 flex-col items-start text-left">
<ModelSelectorName className="text-xs font-normal">
{selectedModel?.display_name}
@ -1636,7 +1859,7 @@ export function InputBox({
</ModelSelector>
<PromptInputSubmit
className="rounded-full"
disabled={disabled}
disabled={composerLocked}
variant="outline"
status={status}
onClick={(e) => {
@ -1749,9 +1972,11 @@ function SuggestionList({
function AddAttachmentsButton({
className,
disabled,
uploadLimits,
}: {
className?: string;
disabled?: boolean;
uploadLimits?: UploadLimits;
}) {
const { t } = useI18n();
@ -1769,6 +1994,7 @@ function AddAttachmentsButton({
aria-label={t.inputBox.addAttachments}
className={cn("px-2!", className)}
data-testid="add-attachments-button"
disabled={disabled}
onClick={() => attachments.openFileDialog()}
>
<PaperclipIcon className="size-3" />

View file

@ -116,6 +116,12 @@ export const enUS: Translations = {
createSkillPrompt:
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
addAttachments: "Add attachments",
inputPolish: "Polish input",
inputPolishing: "Polishing input...",
inputPolishNoChanges: "This input is already clear.",
inputPolishFailed: "Failed to polish input.",
inputPolishUndo: "Undo polish",
inputPolishCancel: "Cancel polishing",
mode: "Mode",
flashMode: "Flash",
flashModeDescription: "Fast and efficient, but may not be accurate",

View file

@ -98,6 +98,12 @@ export interface Translations {
placeholder: string;
createSkillPrompt: string;
addAttachments: string;
inputPolish: string;
inputPolishing: string;
inputPolishNoChanges: string;
inputPolishFailed: string;
inputPolishUndo: string;
inputPolishCancel: string;
mode: string;
flashMode: string;
flashModeDescription: string;

View file

@ -115,6 +115,12 @@ export const zhCN: Translations = {
createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
addAttachments: "添加附件",
inputPolish: "优化输入",
inputPolishing: "正在优化输入...",
inputPolishNoChanges: "当前输入已经足够清晰。",
inputPolishFailed: "优化输入失败。",
inputPolishUndo: "撤销优化",
inputPolishCancel: "取消优化",
mode: "模式",
flashMode: "闪速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准",

View file

@ -0,0 +1,32 @@
import { throwGatewayApiError } from "@/core/api/errors";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
export type InputPolishRequest = {
text: string;
locale?: string;
thread_id?: string;
};
export type InputPolishResponse = {
rewritten_text: string;
changed: boolean;
};
export async function polishInputDraft(
request: InputPolishRequest,
options?: { signal?: AbortSignal },
): Promise<InputPolishResponse> {
const response = await fetch(`${getBackendBaseURL()}/api/input-polish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
signal: options?.signal,
});
if (!response.ok) {
await throwGatewayApiError(response, "Failed to polish input");
}
return response.json() as Promise<InputPolishResponse>;
}

View file

@ -25,6 +25,159 @@ test.describe("Chat workspace", () => {
await expect(textarea).toHaveValue("Hello, DeerFlow!");
});
test("polishes draft input before sending", async ({ page }) => {
let polishRequest: { text?: string; model_name?: string } | undefined;
let submittedText: string | undefined;
let finishPolish!: () => void;
const polishCanFinish = new Promise<void>((resolve) => {
finishPolish = resolve;
});
await page.route("**/api/input-polish", async (route) => {
polishRequest = route.request().postDataJSON() as {
text?: string;
model_name?: string;
};
await polishCanFinish;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
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("summarize report");
await page.getByTestId("polish-input-button").click();
await expect
.poll(() => polishRequest?.text, { timeout: 10_000 })
.toBe("summarize report");
expect(polishRequest?.model_name).toBeUndefined();
await expect(textarea).toBeDisabled();
await expect(page.getByText("Polishing input...")).toBeVisible();
finishPolish();
await expect(textarea).toHaveValue(
"Please summarize the uploaded report clearly.",
);
await expect(textarea).toBeEnabled();
await expect(page.getByTestId("polish-input-button")).toHaveAccessibleName(
"Undo polish",
);
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe("Please summarize the uploaded report clearly.");
});
test("undoes polished draft from the polish button", async ({ page }) => {
await page.route("**/api/input-polish", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
}),
);
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("summarize report");
await page.getByTestId("polish-input-button").click();
await expect(textarea).toHaveValue(
"Please summarize the uploaded report clearly.",
);
const polishButton = page.getByTestId("polish-input-button");
await expect(polishButton).toHaveAccessibleName("Undo polish");
await polishButton.click();
await expect(textarea).toHaveValue("summarize report");
await expect(polishButton).toHaveAccessibleName("Polish input");
});
test("cancels an in-flight polish request", async ({ page }) => {
// Hold the polish response open so the request stays in flight while we
// exercise the cancel affordance.
let releasePolish!: () => void;
const polishHeld = new Promise<void>((resolve) => {
releasePolish = resolve;
});
await page.route("**/api/input-polish", async (route) => {
await polishHeld;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
});
});
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("summarize report");
await page.getByTestId("polish-input-button").click();
await expect(page.getByText("Polishing input...")).toBeVisible();
await expect(textarea).toBeDisabled();
await page.getByTestId("cancel-polish-input-button").click();
// Cancelling aborts the request, re-enables the composer, and leaves the
// original draft untouched (no rewrite applied).
await expect(page.getByText("Polishing input...")).toBeHidden();
await expect(textarea).toBeEnabled();
await expect(textarea).toHaveValue("summarize report");
await expect(page.getByTestId("polish-input-button")).toHaveAccessibleName(
"Polish input",
);
releasePolish();
});
test("suggests matching skills after a leading slash", async ({ page }) => {
await page.goto("/workspace/chats/new");

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from "@rstest/core";
import {
abortGoalRequest,
beginGoalRequest,
canPolishInput,
createGoalRequestState,
findSuggestionTemplatePlaceholder,
finishGoalRequest,
@ -159,6 +160,32 @@ describe("getInputSubmitAction", () => {
});
});
describe("canPolishInput", () => {
it("requires non-empty input", () => {
expect(canPolishInput("")).toBe(false);
expect(canPolishInput(" ")).toBe(false);
});
it("allows ordinary text and slash skill prompts", () => {
expect(canPolishInput("make this clearer")).toBe(true);
expect(canPolishInput("/web-dev build a polished page")).toBe(true);
expect(canPolishInput("/goalkeeper do thing")).toBe(true);
expect(canPolishInput("/helper explain this")).toBe(true);
// `/help` is not a real builtin command in the composer, so it stays
// eligible like any other slash skill prompt.
expect(canPolishInput("/help")).toBe(true);
expect(canPolishInput("/help me")).toBe(true);
});
it("blocks reserved builtin commands", () => {
expect(canPolishInput("/goal")).toBe(false);
expect(canPolishInput("/goal ship this feature")).toBe(false);
expect(canPolishInput("/goal clear")).toBe(false);
expect(canPolishInput("/compact")).toBe(false);
expect(canPolishInput("/context compact")).toBe(false);
});
});
describe("getLeadingSlashSkillQuery", () => {
it("returns the query for a leading slash token", () => {
expect(getLeadingSlashSkillQuery("/rev")).toBe("rev");