feat(phase-06): add @ reference dropdown and chip interactions
This commit is contained in:
parent
c50bfb97a9
commit
6fa62cf7cc
|
|
@ -22,6 +22,8 @@ import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
|
@ -42,6 +44,7 @@ import {
|
||||||
usePromptInputAttachments,
|
usePromptInputAttachments,
|
||||||
usePromptInputController,
|
usePromptInputController,
|
||||||
type PromptInputMessage,
|
type PromptInputMessage,
|
||||||
|
type PromptInputReference,
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfettiButton } from "@/components/ui/confetti-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 { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
|
import { useUploadedFiles } from "@/core/uploads/hooks";
|
||||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
|
|
@ -86,8 +91,35 @@ import {
|
||||||
|
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
import { useThread } from "./messages/context";
|
||||||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
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({
|
export function InputBox({
|
||||||
className,
|
className,
|
||||||
threadId: threadIdFromProps,
|
threadId: threadIdFromProps,
|
||||||
|
|
@ -130,6 +162,7 @@ export function InputBox({
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { thread } = useThread();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
||||||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
||||||
|
|
@ -148,6 +181,11 @@ export function InputBox({
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
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 时禁用收缩,始终保持展开(除非已提交消息)
|
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
||||||
const effectiveIsFocused =
|
const effectiveIsFocused =
|
||||||
|
|
@ -188,6 +226,46 @@ export function InputBox({
|
||||||
() => selectedModel?.supports_thinking ?? false,
|
() => selectedModel?.supports_thinking ?? false,
|
||||||
[selectedModel],
|
[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(
|
const handleModelSelect = useCallback(
|
||||||
(model_name: string) => {
|
(model_name: string) => {
|
||||||
onContextChange?.({
|
onContextChange?.({
|
||||||
|
|
@ -213,7 +291,7 @@ export function InputBox({
|
||||||
onStop?.();
|
onStop?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!message.text) {
|
if (!message.text && references.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
|
|
@ -223,9 +301,13 @@ export function InputBox({
|
||||||
isChatting: true,
|
isChatting: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onSubmit?.(message);
|
onSubmit?.({
|
||||||
|
...message,
|
||||||
|
references,
|
||||||
|
});
|
||||||
|
setReferences([]);
|
||||||
},
|
},
|
||||||
[showWelcomeStyle, onSubmit, onStop, status],
|
[showWelcomeStyle, onSubmit, onStop, references, status],
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestFormSubmit = useCallback(() => {
|
const requestFormSubmit = useCallback(() => {
|
||||||
|
|
@ -233,6 +315,110 @@ export function InputBox({
|
||||||
form?.requestSubmit();
|
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(
|
const handleFollowupClick = useCallback(
|
||||||
(suggestion: string) => {
|
(suggestion: string) => {
|
||||||
if (status === "streaming") return;
|
if (status === "streaming") return;
|
||||||
|
|
@ -308,6 +494,45 @@ export function InputBox({
|
||||||
}}
|
}}
|
||||||
className="relative w-full"
|
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 />
|
<AttachmentPreviewBar />
|
||||||
|
|
||||||
{extraHeader && (
|
{extraHeader && (
|
||||||
|
|
@ -348,8 +573,45 @@ export function InputBox({
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
defaultValue={initialValue}
|
defaultValue={initialValue}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onChange={handleTextareaChange}
|
||||||
|
onKeyDown={handleTextareaKeyDown}
|
||||||
/>
|
/>
|
||||||
</PromptInputBody>
|
</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 && (
|
{!effectiveIsFocused && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-1 cursor-text"
|
className="absolute inset-0 z-1 cursor-text"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue