fix: block unresolved suggestion template placeholders (#3764)

* fix: block unresolved suggestion placeholders

* fix: satisfy suggestions config hook deps
This commit is contained in:
Huixin615 2026-06-25 23:15:58 +08:00 committed by GitHub
parent 73874a9b35
commit b7d2dcc67c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 127 additions and 18 deletions

View file

@ -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<string[]>([]);
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 && (
<div className="flex items-center justify-center pt-2">
<SuggestionList />
<SuggestionList textareaRef={textareaRef} />
</div>
)}
@ -1227,28 +1262,27 @@ export function InputBox({
);
}
function SuggestionList() {
function SuggestionList({
textareaRef,
}: {
textareaRef: RefObject<HTMLTextAreaElement | null>;
}) {
const { t } = useI18n();
const { textInput } = usePromptInputController();
const handleSuggestionClick = useCallback(
(prompt: string | undefined) => {
if (!prompt) return;
textInput.setInput(prompt);
setTimeout(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
"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 (
<Suggestions className="min-h-16 w-full max-w-full justify-center px-4 sm:w-fit sm:px-0">

View file

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

View file

@ -94,6 +94,7 @@ export interface Translations {
followupConfirmDescription: string;
followupConfirmAppend: string;
followupConfirmReplace: string;
suggestionPlaceholderRequired: string;
suggestions: {
suggestion: string;
prompt: string;

View file

@ -111,6 +111,7 @@ export const zhCN: Translations = {
followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
followupConfirmAppend: "追加并发送",
followupConfirmReplace: "替换并发送",
suggestionPlaceholderRequired: "发送前请先填写建议模板中的占位内容。",
suggestions: [
{
suggestion: "写作",

View file

@ -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,
}) => {