diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 6b42bba0..5cfed58c 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -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([]); + 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(() => { + 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(); + [...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) => { + 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) => { + 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 && ( +
+ {references.map((reference) => { + const label = reference.path + ? `${reference.filename} · ${getPathTail(reference.path)}` + : reference.filename; + return ( + + + {label} + + + + ); + })} +
+ )} + {extraHeader && ( @@ -348,8 +573,45 @@ export function InputBox({ autoFocus={autoFocus} defaultValue={initialValue} onFocus={() => setIsFocused(true)} + onChange={handleTextareaChange} + onKeyDown={handleTextareaKeyDown} /> + 0} + onOpenChange={setMentionOpen} + > + +