feat: may_ask (#981)

* feat: u may ask

* chore: adjust code according to CR

* chore: adjust code according to CR

* ut: test for suggestions.py

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
null4536251
2026-03-06 22:39:58 +08:00
committed by GitHub
parent 2e90101be8
commit 9d2144d431
10 changed files with 462 additions and 35 deletions

View File

@@ -151,6 +151,7 @@ export default function AgentChatPage() {
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}

View File

@@ -119,6 +119,7 @@ export default function ChatPage() {
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
threadId={threadId}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}

View File

@@ -9,6 +9,7 @@ import {
PlusIcon,
SparklesIcon,
RocketIcon,
XIcon,
ZapIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
@@ -16,6 +17,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
@@ -38,15 +40,26 @@ import {
usePromptInputController,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { ConfettiButton } from "@/components/ui/confetti-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
import {
@@ -66,6 +79,7 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
@@ -92,6 +106,7 @@ export function InputBox({
context,
extraHeader,
isNewThread,
threadId,
initialValue,
onContextChange,
onSubmit,
@@ -110,6 +125,7 @@ export function InputBox({
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
threadId: string;
initialValue?: string;
onContextChange?: (
context: Omit<
@@ -127,6 +143,20 @@ export function InputBox({
const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const lastGeneratedForAiIdRef = useRef<string | null>(null);
const wasStreamingRef = useRef(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
null,
);
useEffect(() => {
if (models.length === 0) {
@@ -213,43 +243,168 @@ export function InputBox({
if (!message.text) {
return;
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
onSubmit?.(message);
},
[onSubmit, onStop, status],
);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
form?.requestSubmit();
}, []);
const handleFollowupClick = useCallback(
(suggestion: string) => {
if (status === "streaming") {
return;
}
const current = (textInput.value ?? "").trim();
if (current) {
setPendingSuggestion(suggestion);
setConfirmOpen(true);
return;
}
textInput.setInput(suggestion);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
},
[requestFormSubmit, status, textInput],
);
const confirmReplaceAndSend = useCallback(() => {
if (!pendingSuggestion) {
setConfirmOpen(false);
return;
}
textInput.setInput(pendingSuggestion);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const confirmAppendAndSend = useCallback(() => {
if (!pendingSuggestion) {
setConfirmOpen(false);
return;
}
const current = (textInput.value ?? "").trim();
const next = current ? `${current}\n${pendingSuggestion}` : pendingSuggestion;
textInput.setInput(next);
setFollowupsHidden(true);
setConfirmOpen(false);
setPendingSuggestion(null);
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = streaming;
if (!wasStreaming || streaming) {
return;
}
if (disabled || isMock) {
return;
}
const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai");
const lastAiId = lastAi?.id ?? null;
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
return;
}
lastGeneratedForAiIdRef.current = lastAiId;
const recent = thread.messages
.filter((m) => m.type === "human" || m.type === "ai")
.map((m) => {
const role = m.type === "human" ? "user" : "assistant";
const content = textOfMessage(m) ?? "";
return { role, content };
})
.filter((m) => m.content.trim().length > 0)
.slice(-6);
if (recent.length === 0) {
return;
}
const controller = new AbortController();
setFollowupsHidden(false);
setFollowupsLoading(true);
setFollowups([]);
fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: recent,
n: 3,
model_name: context.model_name ?? undefined,
}),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
return { suggestions: [] as string[] };
}
return (await res.json()) as { suggestions?: string[] };
})
.then((data) => {
const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0)
.slice(0, 5);
setFollowups(suggestions);
})
.catch(() => {
setFollowups([]);
})
.finally(() => {
setFollowupsLoading(false);
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
return (
<PromptInput
className={cn(
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
className,
)}
disabled={disabled}
globalDrop
multiple
onSubmit={handleSubmit}
{...props}
>
{extraHeader && (
<div className="absolute top-0 right-0 left-0 z-10">
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center">
{extraHeader}
<div ref={promptRootRef} className="relative">
<PromptInput
className={cn(
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
className,
)}
disabled={disabled}
globalDrop
multiple
onSubmit={handleSubmit}
{...props}
>
{extraHeader && (
<div className="absolute top-0 right-0 left-0 z-10">
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center">
{extraHeader}
</div>
</div>
</div>
)}
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
<PromptInputTextarea
className={cn("size-full")}
disabled={disabled}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
/>
</PromptInputBody>
<PromptInputFooter className="flex">
<PromptInputTools>
)}
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
<PromptInputTextarea
className={cn("size-full")}
disabled={disabled}
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
/>
</PromptInputBody>
<PromptInputFooter className="flex">
<PromptInputTools>
{/* TODO: Add more connectors here
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" />
@@ -588,7 +743,65 @@ export function InputBox({
{!isNewThread && (
<div className="bg-background absolute right-0 -bottom-[17px] left-0 z-0 h-4"></div>
)}
</PromptInput>
</PromptInput>
{!disabled &&
!isNewThread &&
!followupsHidden &&
(followupsLoading || followups.length > 0) && (
<div className="absolute right-0 -top-20 left-0 z-20 flex items-center justify-center">
<div className="flex items-center gap-2">
{followupsLoading ? (
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
{t.inputBox.followupLoading}
</div>
) : (
<Suggestions className="min-h-16 w-fit items-start">
{followups.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={() => handleFollowupClick(s)}
/>
))}
<Button
aria-label={t.common.close}
className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal"
variant="outline"
size="sm"
type="button"
onClick={() => setFollowupsHidden(true)}
>
<XIcon className="size-4" />
</Button>
</Suggestions>
)}
</div>
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.inputBox.followupConfirmTitle}</DialogTitle>
<DialogDescription>
{t.inputBox.followupConfirmDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
{t.common.cancel}
</Button>
<Button variant="secondary" onClick={confirmAppendAndSend}>
{t.inputBox.followupConfirmAppend}
</Button>
<Button onClick={confirmReplaceAndSend}>
{t.inputBox.followupConfirmReplace}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -96,6 +96,12 @@ export const enUS: Translations = {
searchModels: "Search models...",
surpriseMe: "Surprise",
surpriseMePrompt: "Surprise me",
followupLoading: "Generating follow-up questions...",
followupConfirmTitle: "Send suggestion?",
followupConfirmDescription:
"You already have text in the input. Choose how to send it.",
followupConfirmAppend: "Append & send",
followupConfirmReplace: "Replace & send",
suggestions: [
{
suggestion: "Write",

View File

@@ -76,6 +76,11 @@ export interface Translations {
searchModels: string;
surpriseMe: string;
surpriseMePrompt: string;
followupLoading: string;
followupConfirmTitle: string;
followupConfirmDescription: string;
followupConfirmAppend: string;
followupConfirmReplace: string;
suggestions: {
suggestion: string;
prompt: string;

View File

@@ -92,6 +92,11 @@ export const zhCN: Translations = {
searchModels: "搜索模型...",
surpriseMe: "小惊喜",
surpriseMePrompt: "给我一个小惊喜吧",
followupLoading: "正在生成可能的后续问题...",
followupConfirmTitle: "发送建议问题?",
followupConfirmDescription: "当前输入框已有内容,选择发送方式。",
followupConfirmAppend: "追加并发送",
followupConfirmReplace: "替换并发送",
suggestions: [
{
suggestion: "写作",