mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 16:07:53 +00:00
fix: block unresolved suggestion template placeholders (#3764)
* fix: block unresolved suggestion placeholders * fix: satisfy suggestions config hook deps
This commit is contained in:
parent
73874a9b35
commit
b7d2dcc67c
@ -21,7 +21,9 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
@ -93,6 +95,20 @@ import { Tooltip } from "./tooltip";
|
|||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||||
|
|
||||||
const MAX_SKILL_SUGGESTIONS = 6;
|
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 {
|
function getLeadingSlashSkillQuery(value: string): string | null {
|
||||||
if (!value.startsWith("/")) {
|
if (!value.startsWith("/")) {
|
||||||
@ -209,6 +225,8 @@ export function InputBox({
|
|||||||
|
|
||||||
const [followups, setFollowups] = useState<string[]>([]);
|
const [followups, setFollowups] = useState<string[]>([]);
|
||||||
const { data: suggestionsConfig } = useSuggestionsConfig();
|
const { data: suggestionsConfig } = useSuggestionsConfig();
|
||||||
|
const suggestionsConfigLoaded = suggestionsConfig !== undefined;
|
||||||
|
const suggestionsEnabled = suggestionsConfig?.enabled;
|
||||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||||
const [followupsLoading, setFollowupsLoading] = useState(false);
|
const [followupsLoading, setFollowupsLoading] = useState(false);
|
||||||
const [textareaFocused, setTextareaFocused] = useState(false);
|
const [textareaFocused, setTextareaFocused] = useState(false);
|
||||||
@ -356,6 +374,21 @@ export function InputBox({
|
|||||||
if (!message.text.trim() && message.files.length === 0) {
|
if (!message.text.trim() && message.files.length === 0) {
|
||||||
return;
|
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;
|
promptHistoryIndexRef.current = null;
|
||||||
promptHistoryDraftRef.current = "";
|
promptHistoryDraftRef.current = "";
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
@ -390,6 +423,7 @@ export function InputBox({
|
|||||||
resolvedModelName,
|
resolvedModelName,
|
||||||
selectedModel?.supports_thinking,
|
selectedModel?.supports_thinking,
|
||||||
status,
|
status,
|
||||||
|
t.inputBox.suggestionPlaceholderRequired,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -655,7 +689,7 @@ export function InputBox({
|
|||||||
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
|
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (suggestionsConfig === undefined) {
|
if (!suggestionsConfigLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastGeneratedForAiIdRef.current = lastAiId;
|
lastGeneratedForAiIdRef.current = lastAiId;
|
||||||
@ -675,7 +709,7 @@ export function InputBox({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!suggestionsConfig?.enabled) {
|
if (!suggestionsEnabled) {
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -721,8 +755,9 @@ export function InputBox({
|
|||||||
disabled,
|
disabled,
|
||||||
isMock,
|
isMock,
|
||||||
status,
|
status,
|
||||||
|
suggestionsConfigLoaded,
|
||||||
|
suggestionsEnabled,
|
||||||
threadId,
|
threadId,
|
||||||
suggestionsConfig?.enabled,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1198,7 +1233,7 @@ export function InputBox({
|
|||||||
searchParams.get("mode") !== "skill" &&
|
searchParams.get("mode") !== "skill" &&
|
||||||
!showSkillSuggestions && (
|
!showSkillSuggestions && (
|
||||||
<div className="flex items-center justify-center pt-2">
|
<div className="flex items-center justify-center pt-2">
|
||||||
<SuggestionList />
|
<SuggestionList textareaRef={textareaRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1227,28 +1262,27 @@ export function InputBox({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SuggestionList() {
|
function SuggestionList({
|
||||||
|
textareaRef,
|
||||||
|
}: {
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
const handleSuggestionClick = useCallback(
|
const handleSuggestionClick = useCallback(
|
||||||
(prompt: string | undefined) => {
|
(prompt: string | undefined) => {
|
||||||
if (!prompt) return;
|
if (!prompt) return;
|
||||||
textInput.setInput(prompt);
|
textInput.setInput(prompt);
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
const textarea = textareaRef.current;
|
||||||
"textarea[name='message']",
|
const placeholder = findSuggestionTemplatePlaceholder(prompt);
|
||||||
);
|
if (textarea && placeholder) {
|
||||||
if (textarea) {
|
textarea.focus();
|
||||||
const selStart = prompt.indexOf("[");
|
textarea.setSelectionRange(placeholder.start, placeholder.end);
|
||||||
const selEnd = prompt.indexOf("]");
|
|
||||||
if (selStart !== -1 && selEnd !== -1) {
|
|
||||||
textarea.setSelectionRange(selStart, selEnd + 1);
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 500);
|
});
|
||||||
},
|
},
|
||||||
[textInput],
|
[textareaRef, textInput],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Suggestions className="min-h-16 w-full max-w-full justify-center px-4 sm:w-fit sm:px-0">
|
<Suggestions className="min-h-16 w-full max-w-full justify-center px-4 sm:w-fit sm:px-0">
|
||||||
|
|||||||
@ -116,6 +116,8 @@ export const enUS: Translations = {
|
|||||||
"You already have text in the input. Choose how to send it.",
|
"You already have text in the input. Choose how to send it.",
|
||||||
followupConfirmAppend: "Append & send",
|
followupConfirmAppend: "Append & send",
|
||||||
followupConfirmReplace: "Replace & send",
|
followupConfirmReplace: "Replace & send",
|
||||||
|
suggestionPlaceholderRequired:
|
||||||
|
"Replace the suggestion placeholder before sending.",
|
||||||
suggestions: [
|
suggestions: [
|
||||||
{
|
{
|
||||||
suggestion: "Write",
|
suggestion: "Write",
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export interface Translations {
|
|||||||
followupConfirmDescription: string;
|
followupConfirmDescription: string;
|
||||||
followupConfirmAppend: string;
|
followupConfirmAppend: string;
|
||||||
followupConfirmReplace: string;
|
followupConfirmReplace: string;
|
||||||
|
suggestionPlaceholderRequired: string;
|
||||||
suggestions: {
|
suggestions: {
|
||||||
suggestion: string;
|
suggestion: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
|||||||
@ -111,6 +111,7 @@ export const zhCN: Translations = {
|
|||||||
followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
|
followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
|
||||||
followupConfirmAppend: "追加并发送",
|
followupConfirmAppend: "追加并发送",
|
||||||
followupConfirmReplace: "替换并发送",
|
followupConfirmReplace: "替换并发送",
|
||||||
|
suggestionPlaceholderRequired: "发送前请先填写建议模板中的占位内容。",
|
||||||
suggestions: [
|
suggestions: [
|
||||||
{
|
{
|
||||||
suggestion: "写作",
|
suggestion: "写作",
|
||||||
|
|||||||
@ -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 ({
|
test("slash skill command is submitted as normal chat text", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user