mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
feat: add prompt history recall (#3718)
This commit is contained in:
parent
b990da785f
commit
14d9bb87a4
@ -66,6 +66,7 @@ import { useSkills } from "@/core/skills/hooks";
|
|||||||
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -203,6 +204,8 @@ export function InputBox({
|
|||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const promptHistoryIndexRef = useRef<number | null>(null);
|
||||||
|
const promptHistoryDraftRef = useRef("");
|
||||||
|
|
||||||
const [followups, setFollowups] = useState<string[]>([]);
|
const [followups, setFollowups] = useState<string[]>([]);
|
||||||
const { data: suggestionsConfig } = useSuggestionsConfig();
|
const { data: suggestionsConfig } = useSuggestionsConfig();
|
||||||
@ -261,6 +264,44 @@ export function InputBox({
|
|||||||
[selectedModel],
|
[selectedModel],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const promptHistory = useMemo(() => {
|
||||||
|
const history: string[] = [];
|
||||||
|
for (const message of thread.messages) {
|
||||||
|
if (message.type !== "human") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const additionalKwargs = message.additional_kwargs;
|
||||||
|
if (
|
||||||
|
additionalKwargs &&
|
||||||
|
typeof additionalKwargs === "object" &&
|
||||||
|
Reflect.get(additionalKwargs, "hide_from_ui") === true
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = textOfMessage(message)?.trim();
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (history.at(-1) !== text) {
|
||||||
|
history.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return history;
|
||||||
|
}, [thread.messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
promptHistoryIndexRef.current = null;
|
||||||
|
promptHistoryDraftRef.current = "";
|
||||||
|
}, [threadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentIndex = promptHistoryIndexRef.current;
|
||||||
|
if (currentIndex !== null && currentIndex >= promptHistory.length) {
|
||||||
|
promptHistoryIndexRef.current = null;
|
||||||
|
promptHistoryDraftRef.current = "";
|
||||||
|
}
|
||||||
|
}, [promptHistory.length]);
|
||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
(model_name: string) => {
|
(model_name: string) => {
|
||||||
const model = models.find((m) => m.name === model_name);
|
const model = models.find((m) => m.name === model_name);
|
||||||
@ -315,6 +356,8 @@ export function InputBox({
|
|||||||
if (!message.text.trim() && message.files.length === 0) {
|
if (!message.text.trim() && message.files.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
promptHistoryIndexRef.current = null;
|
||||||
|
promptHistoryDraftRef.current = "";
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
setFollowupsHidden(false);
|
setFollowupsHidden(false);
|
||||||
setFollowupsLoading(false);
|
setFollowupsLoading(false);
|
||||||
@ -489,6 +532,91 @@ export function InputBox({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setPromptHistoryValue = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
textInput.setInput(value);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(value.length, value.length);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[textInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePromptHistoryKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (
|
||||||
|
event.altKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
isIMEComposing(event) ||
|
||||||
|
promptHistory.length === 0 ||
|
||||||
|
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = textInput.value ?? "";
|
||||||
|
const currentHistoryIndex = promptHistoryIndexRef.current;
|
||||||
|
const isBrowsingHistory = currentHistoryIndex !== null;
|
||||||
|
|
||||||
|
if (!isBrowsingHistory && currentValue.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
const nextIndex = isBrowsingHistory
|
||||||
|
? Math.max(currentHistoryIndex - 1, 0)
|
||||||
|
: promptHistory.length - 1;
|
||||||
|
if (!isBrowsingHistory) {
|
||||||
|
promptHistoryDraftRef.current = currentValue;
|
||||||
|
}
|
||||||
|
promptHistoryIndexRef.current = nextIndex;
|
||||||
|
setPromptHistoryValue(promptHistory[nextIndex] ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBrowsingHistory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (currentHistoryIndex >= promptHistory.length - 1) {
|
||||||
|
promptHistoryIndexRef.current = null;
|
||||||
|
setPromptHistoryValue(promptHistoryDraftRef.current);
|
||||||
|
promptHistoryDraftRef.current = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = currentHistoryIndex + 1;
|
||||||
|
promptHistoryIndexRef.current = nextIndex;
|
||||||
|
setPromptHistoryValue(promptHistory[nextIndex] ?? "");
|
||||||
|
},
|
||||||
|
[promptHistory, setPromptHistoryValue, textInput.value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePromptTextareaKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
handleSkillSuggestionKeyDown(event);
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePromptHistoryKeyDown(event);
|
||||||
|
},
|
||||||
|
[handlePromptHistoryKeyDown, handleSkillSuggestionKeyDown],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePromptTextareaChange = useCallback(() => {
|
||||||
|
promptHistoryIndexRef.current = null;
|
||||||
|
promptHistoryDraftRef.current = "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
const showFollowups =
|
const showFollowups =
|
||||||
!disabled &&
|
!disabled &&
|
||||||
!isWelcomeMode &&
|
!isWelcomeMode &&
|
||||||
@ -708,8 +836,9 @@ export function InputBox({
|
|||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
defaultValue={initialValue}
|
defaultValue={initialValue}
|
||||||
onBlur={() => setTextareaFocused(false)}
|
onBlur={() => setTextareaFocused(false)}
|
||||||
|
onChange={handlePromptTextareaChange}
|
||||||
onFocus={() => setTextareaFocused(true)}
|
onFocus={() => setTextareaFocused(true)}
|
||||||
onKeyDown={handleSkillSuggestionKeyDown}
|
onKeyDown={handlePromptTextareaKeyDown}
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
/>
|
/>
|
||||||
</PromptInputBody>
|
</PromptInputBody>
|
||||||
|
|||||||
@ -44,6 +44,44 @@ test.describe("Chat workspace", () => {
|
|||||||
await expect(textarea).toHaveValue("/data-analysis ");
|
await expect(textarea).toHaveValue("/data-analysis ");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses arrow keys to navigate skill suggestions before prompt history", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
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("/");
|
||||||
|
|
||||||
|
const dataAnalysis = page.getByRole("option", {
|
||||||
|
name: /data-analysis/i,
|
||||||
|
});
|
||||||
|
const frontendDesign = page.getByRole("option", {
|
||||||
|
name: /frontend-design/i,
|
||||||
|
});
|
||||||
|
await expect(dataAnalysis).toBeVisible();
|
||||||
|
await expect(frontendDesign).toBeVisible();
|
||||||
|
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
await textarea.press("ArrowDown");
|
||||||
|
|
||||||
|
await expect(textarea).toHaveValue("/");
|
||||||
|
await expect(dataAnalysis).toHaveAttribute("aria-selected", "false");
|
||||||
|
await expect(frontendDesign).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
await textarea.press("ArrowUp");
|
||||||
|
|
||||||
|
await expect(textarea).toHaveValue("/");
|
||||||
|
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(frontendDesign).toHaveAttribute("aria-selected", "false");
|
||||||
|
|
||||||
|
await textarea.press("ArrowDown");
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
|
await expect(textarea).toHaveValue("/frontend-design ");
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
|
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@ -65,6 +65,70 @@ test.describe("Thread history", () => {
|
|||||||
).toBeVisible({ timeout: 15_000 });
|
).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("input box recalls previous prompts with arrow keys", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const firstPrompt = "Summarize the latest quarterly report";
|
||||||
|
const secondPrompt = "Turn the summary into an action plan";
|
||||||
|
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "Prompt history conversation",
|
||||||
|
updated_at: "2025-06-03T12:00:00Z",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
id: "msg-human-prompt-history-1",
|
||||||
|
content: [{ type: "text", text: firstPrompt }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "msg-ai-prompt-history-1",
|
||||||
|
content: "First answer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
id: "msg-human-prompt-history-2",
|
||||||
|
content: [{ type: "text", text: secondPrompt }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "msg-ai-prompt-history-2",
|
||||||
|
content: "Second answer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||||
|
await expect(page.getByText("Second answer")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
|
||||||
|
await textarea.focus();
|
||||||
|
await textarea.press("ArrowUp");
|
||||||
|
await expect(textarea).toHaveValue(secondPrompt);
|
||||||
|
|
||||||
|
await textarea.press("ArrowUp");
|
||||||
|
await expect(textarea).toHaveValue(firstPrompt);
|
||||||
|
|
||||||
|
await textarea.press("ArrowDown");
|
||||||
|
await expect(textarea).toHaveValue(secondPrompt);
|
||||||
|
|
||||||
|
await textarea.press("ArrowDown");
|
||||||
|
await expect(textarea).toHaveValue("");
|
||||||
|
|
||||||
|
await textarea.fill("draft should not be overwritten");
|
||||||
|
await textarea.press("ArrowUp");
|
||||||
|
await expect(textarea).toHaveValue("draft should not be overwritten");
|
||||||
|
});
|
||||||
|
|
||||||
test("deleting an inactive chat keeps the current chat open", async ({
|
test("deleting an inactive chat keeps the current chat open", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user