@@ -1270,6 +1431,25 @@ export function InputBox({
)}
+ {polishingInput && (
+
+
+ {t.inputBox.inputPolishing}
+
+
+ )}
{sidecar && sidecar.conversationQuotes.length > 0 && (
*/}
+
+
+ {polishingInput ? (
+
+ ) : inputPolishUndoAvailable ? (
+
+ ) : (
+
+ )}
+
+
-
+
{context.mode === "flash" &&
}
{context.mode === "thinking" && (
@@ -1480,7 +1697,10 @@ export function InputBox({
{supportReasoningEffort && context.mode !== "flash" && (
-
+
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
@@ -1601,7 +1821,10 @@ export function InputBox({
onOpenChange={setModelDialogOpen}
>
-
+
{selectedModel?.display_name}
@@ -1636,7 +1859,7 @@ export function InputBox({
{
@@ -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()}
>
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index 9e96d18ca..dcc49d697 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -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",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index df040d977..af8b04561 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -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;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index f31144994..9542be9ec 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -115,6 +115,12 @@ export const zhCN: Translations = {
createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
addAttachments: "添加附件",
+ inputPolish: "优化输入",
+ inputPolishing: "正在优化输入...",
+ inputPolishNoChanges: "当前输入已经足够清晰。",
+ inputPolishFailed: "优化输入失败。",
+ inputPolishUndo: "撤销优化",
+ inputPolishCancel: "取消优化",
mode: "模式",
flashMode: "闪速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
diff --git a/frontend/src/core/input-polish/api.ts b/frontend/src/core/input-polish/api.ts
new file mode 100644
index 000000000..ab401bdb8
--- /dev/null
+++ b/frontend/src/core/input-polish/api.ts
@@ -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 {
+ 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;
+}
diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts
index 7b9d06e73..fe8bdf156 100644
--- a/frontend/tests/e2e/chat.spec.ts
+++ b/frontend/tests/e2e/chat.spec.ts
@@ -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((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((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");
diff --git a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts
index 4ed026825..d917ea846 100644
--- a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts
+++ b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts
@@ -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");