feat(phase-06): add @ reference dropdown and chip interactions
This commit is contained in:
parent
c50bfb97a9
commit
6fa62cf7cc
|
|
@ -22,6 +22,8 @@ import {
|
|||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
|
||||
|
|
@ -42,6 +44,7 @@ import {
|
|||
usePromptInputAttachments,
|
||||
usePromptInputController,
|
||||
type PromptInputMessage,
|
||||
type PromptInputReference,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfettiButton } from "@/components/ui/confetti-button";
|
||||
|
|
@ -64,8 +67,10 @@ import type { SelectedSkillPayloadItem } from "@/core/i18n/locales/types";
|
|||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { useUploadedFiles } from "@/core/uploads/hooks";
|
||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
ModelSelector,
|
||||
|
|
@ -86,8 +91,35 @@ import {
|
|||
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
import { useThread } from "./messages/context";
|
||||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
|
||||
const MAX_REFERENCES_PER_MESSAGE = 10;
|
||||
|
||||
type MentionCandidate = {
|
||||
key: string;
|
||||
filename: string;
|
||||
path?: string;
|
||||
pathTail: string;
|
||||
ref_source: "artifact" | "upload";
|
||||
ref_kind: "mention";
|
||||
};
|
||||
|
||||
function getPathTail(path: string | undefined): string {
|
||||
if (!path) return "";
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
return segments.slice(-2).join("/");
|
||||
}
|
||||
|
||||
function findMentionToken(text: string, caret: number) {
|
||||
const uptoCaret = text.slice(0, caret);
|
||||
const match = /(?:^|\s)@([^\s@]*)$/.exec(uptoCaret);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { query: match[1] ?? "", start: match.index + match[0].indexOf("@") };
|
||||
}
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
threadId: threadIdFromProps,
|
||||
|
|
@ -130,6 +162,7 @@ export function InputBox({
|
|||
onStop?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { thread } = useThread();
|
||||
const searchParams = useSearchParams();
|
||||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
||||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
||||
|
|
@ -148,6 +181,11 @@ export function InputBox({
|
|||
null,
|
||||
);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [references, setReferences] = useState<PromptInputReference[]>([]);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||||
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
|
||||
|
||||
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
||||
const effectiveIsFocused =
|
||||
|
|
@ -188,6 +226,46 @@ export function InputBox({
|
|||
() => selectedModel?.supports_thinking ?? false,
|
||||
[selectedModel],
|
||||
);
|
||||
|
||||
const mentionCandidates = useMemo<MentionCandidate[]>(() => {
|
||||
const artifactCandidates = (thread.values.artifacts ?? []).map((path) => {
|
||||
const filename = path.split("/").pop() ?? path;
|
||||
return {
|
||||
key: `artifact:${path}`,
|
||||
filename,
|
||||
path,
|
||||
pathTail: getPathTail(path),
|
||||
ref_source: "artifact" as const,
|
||||
ref_kind: "mention" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const uploadCandidates =
|
||||
uploadedFilesData?.files.map((file) => ({
|
||||
key: `upload:${file.virtual_path || file.filename}`,
|
||||
filename: file.filename,
|
||||
path: file.virtual_path,
|
||||
pathTail: getPathTail(file.virtual_path),
|
||||
ref_source: "upload" as const,
|
||||
ref_kind: "mention" as const,
|
||||
})) ?? [];
|
||||
|
||||
const deduped = new Map<string, MentionCandidate>();
|
||||
[...artifactCandidates, ...uploadCandidates].forEach((candidate) => {
|
||||
deduped.set(candidate.key, candidate);
|
||||
});
|
||||
return [...deduped.values()];
|
||||
}, [thread.values.artifacts, uploadedFilesData?.files]);
|
||||
|
||||
const filteredMentionCandidates = useMemo(() => {
|
||||
const query = mentionQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return mentionCandidates;
|
||||
}
|
||||
return mentionCandidates.filter((candidate) =>
|
||||
`${candidate.filename} ${candidate.pathTail}`.toLowerCase().includes(query),
|
||||
);
|
||||
}, [mentionCandidates, mentionQuery]);
|
||||
const handleModelSelect = useCallback(
|
||||
(model_name: string) => {
|
||||
onContextChange?.({
|
||||
|
|
@ -213,7 +291,7 @@ export function InputBox({
|
|||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (!message.text) {
|
||||
if (!message.text && references.length === 0) {
|
||||
return;
|
||||
}
|
||||
setIsFocused(false);
|
||||
|
|
@ -223,9 +301,13 @@ export function InputBox({
|
|||
isChatting: true,
|
||||
});
|
||||
}
|
||||
onSubmit?.(message);
|
||||
onSubmit?.({
|
||||
...message,
|
||||
references,
|
||||
});
|
||||
setReferences([]);
|
||||
},
|
||||
[showWelcomeStyle, onSubmit, onStop, status],
|
||||
[showWelcomeStyle, onSubmit, onStop, references, status],
|
||||
);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
|
|
@ -233,6 +315,110 @@ export function InputBox({
|
|||
form?.requestSubmit();
|
||||
}, []);
|
||||
|
||||
const selectMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate) => {
|
||||
setReferences((prev) => {
|
||||
const exists = prev.some(
|
||||
(item) =>
|
||||
item.ref_source === candidate.ref_source &&
|
||||
item.path === candidate.path &&
|
||||
item.filename === candidate.filename,
|
||||
);
|
||||
if (exists) {
|
||||
return prev;
|
||||
}
|
||||
if (prev.length >= MAX_REFERENCES_PER_MESSAGE) {
|
||||
toast.error("单条消息最多引用 10 个文件");
|
||||
return prev;
|
||||
}
|
||||
return prev.concat({
|
||||
filename: candidate.filename,
|
||||
path: candidate.path,
|
||||
ref_kind: "mention",
|
||||
ref_source: candidate.ref_source,
|
||||
});
|
||||
});
|
||||
|
||||
const current = textInput.value ?? "";
|
||||
const token = findMentionToken(current, current.length);
|
||||
if (token) {
|
||||
const before = current.slice(0, token.start);
|
||||
const after = current.slice(current.length);
|
||||
textInput.setInput(`${before}${after}`.trimEnd());
|
||||
}
|
||||
setMentionQuery("");
|
||||
setMentionOpen(false);
|
||||
setActiveMentionIndex(0);
|
||||
},
|
||||
[textInput],
|
||||
);
|
||||
|
||||
const handleTextareaChange = useCallback(
|
||||
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.currentTarget.value;
|
||||
const caret = event.currentTarget.selectionStart ?? value.length;
|
||||
const token = findMentionToken(value, caret);
|
||||
if (!token) {
|
||||
setMentionOpen(false);
|
||||
setMentionQuery("");
|
||||
setActiveMentionIndex(0);
|
||||
return;
|
||||
}
|
||||
setMentionQuery(token.query);
|
||||
setMentionOpen(true);
|
||||
setActiveMentionIndex(0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTextareaKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === "Backspace" &&
|
||||
event.currentTarget.value === "" &&
|
||||
references.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
setReferences((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (!mentionOpen || filteredMentionCandidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex((prev) =>
|
||||
(prev + 1) % filteredMentionCandidates.length,
|
||||
);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex((prev) =>
|
||||
(prev - 1 + filteredMentionCandidates.length) %
|
||||
filteredMentionCandidates.length,
|
||||
);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selected = filteredMentionCandidates[activeMentionIndex];
|
||||
if (selected) {
|
||||
selectMentionCandidate(selected);
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMentionOpen(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeMentionIndex,
|
||||
filteredMentionCandidates,
|
||||
mentionOpen,
|
||||
references.length,
|
||||
selectMentionCandidate,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFollowupClick = useCallback(
|
||||
(suggestion: string) => {
|
||||
if (status === "streaming") return;
|
||||
|
|
@ -308,6 +494,45 @@ export function InputBox({
|
|||
}}
|
||||
className="relative w-full"
|
||||
>
|
||||
{references.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-1 flex max-w-full flex-wrap gap-2">
|
||||
{references.map((reference) => {
|
||||
const label = reference.path
|
||||
? `${reference.filename} · ${getPathTail(reference.path)}`
|
||||
: reference.filename;
|
||||
return (
|
||||
<Tag
|
||||
key={`${reference.ref_source}:${reference.path ?? reference.filename}`}
|
||||
className="max-w-[260px] shrink-0"
|
||||
>
|
||||
<span className="truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
aria-label="移除引用"
|
||||
className="ml-1 rounded-full"
|
||||
onClick={() =>
|
||||
setReferences((prev) =>
|
||||
prev.filter(
|
||||
(item) =>
|
||||
!(
|
||||
item.ref_source === reference.ref_source &&
|
||||
item.path === reference.path &&
|
||||
item.filename === reference.filename
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AttachmentPreviewBar />
|
||||
|
||||
{extraHeader && (
|
||||
|
|
@ -348,8 +573,45 @@ export function InputBox({
|
|||
autoFocus={autoFocus}
|
||||
defaultValue={initialValue}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<DropdownMenu
|
||||
open={mentionOpen && filteredMentionCandidates.length > 0}
|
||||
onOpenChange={setMentionOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="hidden" type="button" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[360px] p-2"
|
||||
>
|
||||
{filteredMentionCandidates.slice(0, 20).map((candidate, index) => {
|
||||
const detail = candidate.pathTail || candidate.ref_source;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={candidate.key}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md",
|
||||
index === activeMentionIndex && "bg-accent",
|
||||
)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
selectMentionCandidate(candidate);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{candidate.filename}</span>
|
||||
<span className="text-muted-foreground ml-4 shrink-0 text-xs">
|
||||
{detail}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!effectiveIsFocused && (
|
||||
<div
|
||||
className="absolute inset-0 z-1 cursor-text"
|
||||
|
|
|
|||
Loading…
Reference in New Issue