From b7d2dcc67cc44381bcec7987bf0cbc511393993e Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Thu, 25 Jun 2026 23:15:58 +0800 Subject: [PATCH] fix: block unresolved suggestion template placeholders (#3764) * fix: block unresolved suggestion placeholders * fix: satisfy suggestions config hook deps --- .../src/components/workspace/input-box.tsx | 70 +++++++++++++----- frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 1 + frontend/src/core/i18n/locales/zh-CN.ts | 1 + frontend/tests/e2e/chat.spec.ts | 71 +++++++++++++++++++ 5 files changed, 127 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 57133ad16..766ea5998 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -21,7 +21,9 @@ import { useState, type ComponentProps, type KeyboardEvent, + type RefObject, } from "react"; +import { toast } from "sonner"; import { PromptInput, @@ -93,6 +95,20 @@ import { Tooltip } from "./tooltip"; type InputMode = "flash" | "thinking" | "pro" | "ultra"; const MAX_SKILL_SUGGESTIONS = 6; +const SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN = + /\[(?:主题|来源|topic|source)\]/i; + +function findSuggestionTemplatePlaceholder(text: string) { + const match = SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN.exec(text); + if (!match) { + return null; + } + + return { + start: match.index, + end: match.index + match[0].length, + }; +} function getLeadingSlashSkillQuery(value: string): string | null { if (!value.startsWith("/")) { @@ -209,6 +225,8 @@ export function InputBox({ const [followups, setFollowups] = useState([]); const { data: suggestionsConfig } = useSuggestionsConfig(); + const suggestionsConfigLoaded = suggestionsConfig !== undefined; + const suggestionsEnabled = suggestionsConfig?.enabled; const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false); const [textareaFocused, setTextareaFocused] = useState(false); @@ -356,6 +374,21 @@ export function InputBox({ if (!message.text.trim() && message.files.length === 0) { return; } + const placeholder = findSuggestionTemplatePlaceholder(message.text); + if (placeholder) { + toast.error(t.inputBox.suggestionPlaceholderRequired); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + textarea.focus(); + textarea.setSelectionRange(placeholder.start, placeholder.end); + }); + return Promise.reject( + new Error("Suggestion template placeholder is unresolved."), + ); + } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setFollowups([]); @@ -390,6 +423,7 @@ export function InputBox({ resolvedModelName, selectedModel?.supports_thinking, status, + t.inputBox.suggestionPlaceholderRequired, ], ); @@ -655,7 +689,7 @@ export function InputBox({ if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { return; } - if (suggestionsConfig === undefined) { + if (!suggestionsConfigLoaded) { return; } lastGeneratedForAiIdRef.current = lastAiId; @@ -675,7 +709,7 @@ export function InputBox({ return; } - if (!suggestionsConfig?.enabled) { + if (!suggestionsEnabled) { setFollowups([]); return; } @@ -721,8 +755,9 @@ export function InputBox({ disabled, isMock, status, + suggestionsConfigLoaded, + suggestionsEnabled, threadId, - suggestionsConfig?.enabled, ]); return ( @@ -1198,7 +1233,7 @@ export function InputBox({ searchParams.get("mode") !== "skill" && !showSkillSuggestions && (
- +
)} @@ -1227,28 +1262,27 @@ export function InputBox({ ); } -function SuggestionList() { +function SuggestionList({ + textareaRef, +}: { + textareaRef: RefObject; +}) { const { t } = useI18n(); const { textInput } = usePromptInputController(); const handleSuggestionClick = useCallback( (prompt: string | undefined) => { if (!prompt) return; textInput.setInput(prompt); - setTimeout(() => { - const textarea = document.querySelector( - "textarea[name='message']", - ); - if (textarea) { - const selStart = prompt.indexOf("["); - const selEnd = prompt.indexOf("]"); - if (selStart !== -1 && selEnd !== -1) { - textarea.setSelectionRange(selStart, selEnd + 1); - textarea.focus(); - } + requestAnimationFrame(() => { + const textarea = textareaRef.current; + const placeholder = findSuggestionTemplatePlaceholder(prompt); + if (textarea && placeholder) { + textarea.focus(); + textarea.setSelectionRange(placeholder.start, placeholder.end); } - }, 500); + }); }, - [textInput], + [textareaRef, textInput], ); return ( diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 60031263f..a6fc0e79a 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -116,6 +116,8 @@ export const enUS: Translations = { "You already have text in the input. Choose how to send it.", followupConfirmAppend: "Append & send", followupConfirmReplace: "Replace & send", + suggestionPlaceholderRequired: + "Replace the suggestion placeholder before sending.", suggestions: [ { suggestion: "Write", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index a3b2664c7..62ab73405 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -94,6 +94,7 @@ export interface Translations { followupConfirmDescription: string; followupConfirmAppend: string; followupConfirmReplace: string; + suggestionPlaceholderRequired: string; suggestions: { suggestion: string; prompt: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 1f0e0ed59..879f13c08 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -111,6 +111,7 @@ export const zhCN: Translations = { followupConfirmDescription: "当前输入框已有内容,选择发送方式。", followupConfirmAppend: "追加并发送", followupConfirmReplace: "替换并发送", + suggestionPlaceholderRequired: "发送前请先填写建议模板中的占位内容。", suggestions: [ { suggestion: "写作", diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 5b8533b84..5dc2e9ddc 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -143,6 +143,77 @@ test.describe("Chat workspace", () => { }); }); + test("blocks suggestion template placeholders until replaced", async ({ + page, + }) => { + let streamCalled = false; + let submittedText: string | undefined; + await page.route("**/runs/stream", (route) => { + streamCalled = true; + 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 page.getByRole("button", { name: /research/i }).click(); + await expect(textarea).toHaveValue( + "Conduct a deep dive research on [topic], and summarize the findings.", + ); + + await textarea.press("Enter"); + await page.waitForTimeout(500); + + expect(streamCalled).toBe(false); + await expect(textarea).toHaveValue( + "Conduct a deep dive research on [topic], and summarize the findings.", + ); + await expect + .poll( + () => + textarea.evaluate((element) => { + const input = element as HTMLTextAreaElement; + return input.value.slice(input.selectionStart, input.selectionEnd); + }), + { timeout: 5_000 }, + ) + .toBe("[topic]"); + + await textarea.pressSequentially("AI agents"); + await expect(textarea).toHaveValue( + "Conduct a deep dive research on AI agents, and summarize the findings.", + ); + + await textarea.press("Enter"); + + await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy(); + await expect + .poll(() => submittedText, { timeout: 10_000 }) + .toBe( + "Conduct a deep dive research on AI agents, and summarize the findings.", + ); + }); + test("slash skill command is submitted as normal chat text", async ({ page, }) => {