feat(phase-06): add @ reference dropdown and chip interactions

This commit is contained in:
肖应宇 2026-04-15 10:19:09 +08:00
parent c50bfb97a9
commit 6fa62cf7cc
1 changed files with 265 additions and 3 deletions

View File

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