mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
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:
parent
e3137b13cf
commit
01dc067997
22 changed files with 925 additions and 43 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
107
backend/app/gateway/routers/input_polish.py
Normal file
107
backend/app/gateway/routers/input_polish.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
72
backend/packages/harness/deerflow/utils/oneshot_llm.py
Normal file
72
backend/packages/harness/deerflow/utils/oneshot_llm.py
Normal 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)
|
||||
195
backend/tests/test_input_polish_router.py
Normal file
195
backend/tests/test_input_polish_router.py
Normal 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
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -115,6 +115,12 @@ export const zhCN: Translations = {
|
|||
createSkillPrompt:
|
||||
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
||||
addAttachments: "添加附件",
|
||||
inputPolish: "优化输入",
|
||||
inputPolishing: "正在优化输入...",
|
||||
inputPolishNoChanges: "当前输入已经足够清晰。",
|
||||
inputPolishFailed: "优化输入失败。",
|
||||
inputPolishUndo: "撤销优化",
|
||||
inputPolishCancel: "取消优化",
|
||||
mode: "模式",
|
||||
flashMode: "闪速",
|
||||
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
||||
|
|
|
|||
32
frontend/src/core/input-polish/api.ts
Normal file
32
frontend/src/core/input-polish/api.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue