"use client"; import type { ChatStatus } from "ai"; import { Tour } from "antd"; import { CheckIcon, GraduationCapIcon, LightbulbIcon, Loader2Icon, PaperclipIcon, PlusIcon, SparklesIcon, RocketIcon, XIcon, ZapIcon, } from "lucide-react"; import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import { useRouter } from "next/navigation"; import { useSearchParams } from "next/navigation"; import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ComponentProps, type RefObject, } from "react"; import { toast } from "sonner"; import { PromptInput, PromptInputActionMenu, PromptInputActionMenuContent, PromptInputActionMenuItem, PromptInputActionMenuTrigger, PromptInputAttachment, PromptInputAttachments, PromptInputBody, PromptInputButton, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, PromptInputTools, 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"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tag } from "@/components/ui/tag"; import { useReferenceFiles } from "@/core/artifacts/references"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; 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 { MENTION_REFERENCE_EVENT, type MentionReferenceEventDetail, } from "@/core/threads/reference-events"; import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { cn } from "@/lib/utils"; import { ModelSelector, ModelSelectorContent, ModelSelectorInput, ModelSelectorItem, ModelSelectorList, ModelSelectorName, ModelSelectorTrigger, } from "../ai-elements/model-selector"; import { Suggestion, Suggestions } from "../ai-elements/suggestion"; import { ScrollArea } from "../ui/scroll-area"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; const MAX_REFERENCES_PER_MESSAGE = 10; const INPUT_TOOLS_TOUR_SEEN_KEY = "workspace.input_tools_tour_seen.v1"; type InputToolsTourSeenState = { seen: boolean; threadIds?: string[]; }; function parseInputToolsTourSeenState( value: string | null, ): InputToolsTourSeenState | null { if (!value) return null; if (value === "1") { return { seen: true }; } try { const parsed = JSON.parse(value) as InputToolsTourSeenState & { threadId?: string; }; if (typeof parsed?.seen !== "boolean") { return null; } if ( parsed.threadIds != null && (!Array.isArray(parsed.threadIds) || parsed.threadIds.some((id) => typeof id !== "string")) ) { return null; } return { seen: parsed.seen, threadIds: parsed.threadIds ?? (typeof parsed.threadId === "string" ? [parsed.threadId] : undefined), }; } catch { return null; } } type WorkspaceToolButtonProps = ComponentProps; function WorkspaceToolButton({ className, ...props }: WorkspaceToolButtonProps) { return ( ); } type MentionCandidate = { key: string; filename: string; path?: string; pathTail: string; ref_source: "artifact" | "upload"; ref_kind: "mention"; typeLabel: string; isImage: boolean; previewUrl?: string; }; const IMAGE_EXTENSIONS = new Set([ "jpg", "jpeg", "png", "webp", "gif", "bmp", "svg", "avif", ]); function isImageFilename(filename: string): boolean { const parts = filename.toLowerCase().split("."); if (parts.length < 2) return false; return IMAGE_EXTENSIONS.has(parts[parts.length - 1] ?? ""); } function fileExtensionLabel(filename: string): string { const parts = filename.split("."); if (parts.length < 2) return "FILE"; return (parts[parts.length - 1] ?? "FILE").toUpperCase().slice(0, 4); } 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 atIndex = uptoCaret.lastIndexOf("@"); if (atIndex < 0) { return null; } const query = uptoCaret.slice(atIndex + 1); if (/\s/.test(query)) { return null; } return { query, start: atIndex, end: caret }; } export function InputBox({ className, threadId: threadIdFromProps, disabled, autoFocus, status, context, extraHeader, showWelcomeStyle, hasSubmitted, initialValue, onContextChange, onSubmit, onStop, ...props }: Omit, "onSubmit"> & { assistantId?: string | null; threadId: string; status?: ChatStatus; disabled?: boolean; context: Omit< AgentThreadContext, "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" > & { mode: "flash" | "thinking" | "pro" | "ultra" | undefined; }; extraHeader?: React.ReactNode; showWelcomeStyle: boolean; hasSubmitted?: boolean; initialValue?: string; onContextChange?: ( context: Omit< AgentThreadContext, "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" > & { mode: "flash" | "thinking" | "pro" | "ultra" | undefined; }, ) => void; onSubmit?: (message: PromptInputMessage) => void; onStop?: () => void; }) { const { t } = useI18n(); const referenceSourceLabels = useMemo( () => ({ artifact: t.inputBox.referenceSourceArtifact, upload: t.inputBox.referenceSourceUpload, }), [t], ); const searchParams = useSearchParams(); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps }); const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping; const router = useRouter(); const threadId = threadIdFromProps; const { textInput } = usePromptInputController(); const attachments = usePromptInputAttachments(); const promptRootRef = useRef(null); const textareaRef = useRef(null); const containerRef = useRef(null); const mentionTriggerRef = useRef(null); const historyButtonTourRef = useRef(null); const attachmentsButtonTourRef = useRef(null); const skillButtonTourRef = useRef(null); const suggestionListTourRef = useRef(null); const [followups, setFollowups] = useState([]); const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [pendingSuggestion, setPendingSuggestion] = useState( 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 [mentionRange, setMentionRange] = useState<{ start: number; end: number; } | null>(null); const [isInputToolsTourOpen, setIsInputToolsTourOpen] = useState(false); const [isInputToolsTourReady, setIsInputToolsTourReady] = useState(false); const { data: referenceFilesData } = useReferenceFiles(threadIdFromProps); // isNewThread 时禁用收缩,始终保持展开(除非已提交消息) const effectiveIsFocused = ((showWelcomeStyle ?? false) && !hasSubmitted) || isFocused; const shouldShowSuggestionList = showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill"; useEffect(() => { if (!showWelcomeStyle || hasSubmitted) { setIsInputToolsTourReady(false); return; } const frameId = window.requestAnimationFrame(() => { setIsInputToolsTourReady( Boolean( historyButtonTourRef.current && attachmentsButtonTourRef.current && skillButtonTourRef.current && (!shouldShowSuggestionList || suggestionListTourRef.current), ), ); }); return () => window.cancelAnimationFrame(frameId); }, [ showWelcomeStyle, hasSubmitted, shouldShowSuggestionList, iframeSkill.isBootstrapping, iframeSkill.selectedSkills.length, ]); useEffect(() => { if (!showWelcomeStyle || hasSubmitted || !isInputToolsTourReady) { setIsInputToolsTourOpen(false); return; } const seenState = parseInputToolsTourSeenState( window.localStorage.getItem(INPUT_TOOLS_TOUR_SEEN_KEY), ); const hasSeenTourForCurrentThread = seenState?.seen === true && Boolean(seenState.threadIds?.includes(threadId)); if (!hasSeenTourForCurrentThread) { setIsInputToolsTourOpen(true); } }, [showWelcomeStyle, hasSubmitted, isInputToolsTourReady, threadId]); const finishInputToolsTour = useCallback(() => { const seenState = parseInputToolsTourSeenState( window.localStorage.getItem(INPUT_TOOLS_TOUR_SEEN_KEY), ); const seenThreadIds = new Set(seenState?.threadIds ?? []); seenThreadIds.add(threadId); window.localStorage.setItem( INPUT_TOOLS_TOUR_SEEN_KEY, JSON.stringify({ seen: true, threadIds: Array.from(seenThreadIds), } satisfies InputToolsTourSeenState), ); setIsInputToolsTourOpen(false); }, [threadId]); const closeInputToolsTour = useCallback(() => { setIsInputToolsTourOpen(false); }, []); const inputToolsTourSteps = useMemo(() => { const baseSteps = [ { title: "查看历史", description: "点击这里,可以查看历史会话与文档。", target: () => historyButtonTourRef.current ?? document.body, }, { title: "上传附件", description: "点击这里,上传参考文档或拟处理的文档。", target: () => attachmentsButtonTourRef.current ?? document.body, }, { title: "选择 Skill", description: ( <> 点击这里,从“我的skill”中选择要使用的skill。
在广场中选择skill,在详情页选择“去使用”,也可选中skill。 ), target: () => skillButtonTourRef.current ?? document.body, }, ...(shouldShowSuggestionList ? [ { title: "试试我吧", target: () => suggestionListTourRef.current ?? document.body, }, ] : []), ]; return baseSteps.map((step, index) => ({ ...step, prevButtonProps: { children: "上一步" }, nextButtonProps: { children: index === baseSteps.length - 1 ? "完成" : "下一步", }, })); }, [shouldShowSuggestionList]); // 点击外部区域时收起输入框 useEffect(() => { if (!isFocused) return; const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) ) { setIsFocused(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isFocused]); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); const selectedModel = useMemo(() => { if (!context.model_name && models.length > 0) { const model = models[0]!; setTimeout(() => { onContextChange?.({ ...context, model_name: model.name, mode: model.supports_thinking ? "pro" : "flash", }); }, 0); return model; } return models.find((m) => m.name === context.model_name); }, [context, models, onContextChange]); const supportThinking = useMemo( () => selectedModel?.supports_thinking ?? false, [selectedModel], ); const mentionCandidates = useMemo(() => { const deduped = new Map(); (referenceFilesData?.files ?? []).forEach((file) => { const path = file.virtual_path || ""; const filename = file.filename ?? path.split("/").pop() ?? path; const refSource = file.source === "upload" ? "upload" : "artifact"; const typeLabel = refSource === "upload" ? referenceSourceLabels.upload : referenceSourceLabels.artifact; const previewUrl = file.artifact_url || (threadId ? urlOfArtifact({ filepath: path, threadId, }) : undefined); deduped.set(`${refSource}:${path || filename}`, { key: `${refSource}:${path || filename}`, filename, path, pathTail: getPathTail(path), ref_source: refSource, ref_kind: "mention", typeLabel, isImage: isImageFilename(filename), previewUrl, }); }); return [...deduped.values()]; }, [ referenceFilesData?.files, referenceSourceLabels.artifact, referenceSourceLabels.upload, threadId, ]); const filteredMentionCandidates = useMemo(() => { const query = mentionQuery.trim().toLowerCase(); if (!query) { return mentionCandidates; } return mentionCandidates.filter((candidate) => `${candidate.filename} ${candidate.typeLabel} ${candidate.pathTail}` .toLowerCase() .includes(query), ); }, [mentionCandidates, mentionQuery]); const handleModelSelect = useCallback( (model_name: string) => { onContextChange?.({ ...context, model_name, }); setModelDialogOpen(false); }, [onContextChange, context], ); const handleModeSelect = useCallback( (mode: "flash" | "thinking" | "pro" | "ultra") => { onContextChange?.({ ...context, mode, }); }, [onContextChange, context], ); const handleSubmit = useCallback( async (message: PromptInputMessage) => { if (status === "streaming") { onStop?.(); return; } if (!message.text && references.length === 0) { return; } setIsFocused(false); if (showWelcomeStyle) { sendToParent({ type: POST_MESSAGE_TYPES.IS_CHATTING, isChatting: true, }); } onSubmit?.({ ...message, references, selectedSkills: iframeSkill.selectedSkills, }); setReferences([]); }, [ showWelcomeStyle, onSubmit, onStop, references, status, iframeSkill.selectedSkills, ], ); const requestFormSubmit = useCallback(() => { const form = promptRootRef.current?.querySelector("form"); form?.requestSubmit(); }, []); const addMentionReference = useCallback( (reference: PromptInputReference) => { setReferences((prev) => { const exists = prev.some( (item) => item.ref_source === reference.ref_source && item.path === reference.path && item.filename === reference.filename, ); if (exists) { return prev; } if (prev.length >= MAX_REFERENCES_PER_MESSAGE) { toast.error(t.inputBox.maxReferencesReached); return prev; } return prev.concat(reference); }); }, [t.inputBox.maxReferencesReached], ); const selectMentionCandidate = useCallback( (candidate: MentionCandidate) => { addMentionReference({ filename: candidate.filename, path: candidate.path, ref_kind: "mention", ref_source: candidate.ref_source, }); const current = textInput.value ?? ""; const range = mentionRange ?? findMentionToken(current, current.length); if (range) { const before = current.slice(0, range.start); const after = current.slice(range.end); const nextInput = `${before}${after}`; textInput.setInput(nextInput); requestAnimationFrame(() => { textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(before.length, before.length); }); } setMentionQuery(""); setMentionOpen(false); setActiveMentionIndex(0); setMentionRange(null); setIsFocused(true); }, [addMentionReference, mentionRange, textInput], ); useEffect(() => { const onMentionReference = (event: Event) => { const detail = (event as CustomEvent).detail; if (detail?.threadId !== threadIdFromProps) { return; } addMentionReference({ filename: detail.filename, path: detail.path, ref_kind: "mention", ref_source: detail.ref_source, }); setIsFocused(true); requestAnimationFrame(() => { textareaRef.current?.focus(); }); }; window.addEventListener(MENTION_REFERENCE_EVENT, onMentionReference); return () => { window.removeEventListener(MENTION_REFERENCE_EVENT, onMentionReference); }; }, [addMentionReference, threadIdFromProps]); 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); setMentionRange(null); return; } setMentionQuery(token.query); setMentionRange({ start: token.start, end: token.end }); 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); setMentionRange(null); } }, [ activeMentionIndex, filteredMentionCandidates, mentionOpen, references.length, selectMentionCandidate, ], ); 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) return; textInput?.setInput(pendingSuggestion); setFollowupsHidden(true); setConfirmOpen(false); setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); const confirmAppendAndSend = useCallback(() => { if (!pendingSuggestion) return; const current = (textInput?.value ?? "").trim(); textInput?.setInput( current ? `${current}\n${pendingSuggestion}` : pendingSuggestion, ); setFollowupsHidden(true); setConfirmOpen(false); setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); useEffect(() => { if (mentionOpen) { mentionTriggerRef.current?.focus(); } }, [mentionOpen]); useEffect(() => { /* // 暂时禁用 suggestions 接口(/api/threads/{thread_id}/suggestions) if (!threadId || isNewThread || disabled) return; const controller = new AbortController(); setFollowupsHidden(false); setFollowupsLoading(true); setFollowups([]); fetch(`/api/threads/${String(threadId)}/suggestions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [], n: 3 }), signal: controller.signal, }) .then(async (res) => { if (!res.ok) return { suggestions: [] }; return await res.json(); }) .then((data) => { const suggestions = (data.suggestions ?? []) .filter(Boolean) .slice(0, 5); setFollowups(suggestions); }) .catch(() => setFollowups([])) .finally(() => setFollowupsLoading(false)); return () => controller.abort(); */ }, [disabled, showWelcomeStyle, threadId]); return (
{ promptRootRef.current = el; containerRef.current = el; }} className="relative w-full" > setReferences((prev) => prev.filter( (item) => !( item.ref_source === reference.ref_source && item.path === reference.path && item.filename === reference.filename ), ), ) } /> {extraHeader && ( 0 || references.length > 0} > {extraHeader} )} setIsFocused(true)} onChange={handleTextareaChange} onKeyDown={handleTextareaKeyDown} /> 0} onOpenChange={(open) => { setMentionOpen(open); if (!open) { setMentionRange(null); } }} > )}
)} {t.inputBox.followupConfirmTitle} {t.inputBox.followupConfirmDescription} ); } // SuggestionList 容器 const SuggestionListContainer = forwardRef Promise; isBootstrapping: boolean; }>( function SuggestionListContainer( { bootstrapAndLockSkills, isBootstrapping }, ref, ) { return (
); }, ); // 快速选择skillbutton function SuggestionList({ bootstrapAndLockSkills, isBootstrapping, }: { bootstrapAndLockSkills: (params: { selectedSkills: SelectedSkillPayloadItem[]; title: string; }) => Promise; isBootstrapping: boolean; }) { const { t } = useI18n(); const { textInput } = usePromptInputController(); const suggestions = t.inputBox.suggestions; const promptSuggestions = suggestions.filter( ( suggestion, ): suggestion is Exclude< (typeof suggestions)[number], { type: "separator" } > => !("type" in suggestion), ); const handleSuggestionClick = useCallback( (suggestion: { prompt: string; skill_id?: string[]; children?: SelectedSkillPayloadItem[]; suggestion: string; }) => { if (isBootstrapping) return; // 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示) const childSkills = (suggestion.children ?? []) .map((item) => ({ id: String(item.id).trim(), name: item.name?.trim() ?? "", })) .filter( (item): item is { id: string; name: string } => Boolean(item.id) && Boolean(item.name), ); if (childSkills.length > 0) { void bootstrapAndLockSkills({ selectedSkills: childSkills, title: suggestion.suggestion, }); return; } if (suggestion.skill_id && suggestion.skill_id.length > 0) { void bootstrapAndLockSkills({ selectedSkills: suggestion.skill_id.map((id) => ({ id, name: suggestion.suggestion, })), title: suggestion.suggestion, }); return; } // 原有逻辑 if (!suggestion.prompt) return; textInput.setInput(suggestion.prompt); setTimeout(() => { const textarea = document.querySelector( "textarea[name='message']", ); if (textarea) { const selStart = suggestion.prompt.indexOf("["); const selEnd = suggestion.prompt.indexOf("]"); if (selStart !== -1 && selEnd !== -1) { textarea.setSelectionRange(selStart, selEnd + 1); textarea.focus(); } } }, 500); }, [bootstrapAndLockSkills, isBootstrapping, textInput], ); return ( {promptSuggestions.map((suggestion) => ( handleSuggestionClick(suggestion)} /> ))} ); } function AddAttachmentsButton({ className }: { className?: string }) { const { t } = useI18n(); const attachments = usePromptInputAttachments(); return ( attachments.openFileDialog()} > ); } function HistoryButton({ className, router, threadId, }: { className?: string; router: AppRouterInstance; threadId: string; }) { const { t } = useI18n(); return ( router.replace(`/workspace/chats/${threadId}?is_chatting=true`) } > ); } function ExitChattingButton({ className, router, threadId, }: { className?: string; router: AppRouterInstance; threadId: string; }) { const { t } = useI18n(); return ( router.replace(`/workspace/chats/${threadId}?is_chatting=false`) } > ); } // 启动iframeSkillDialog function IframeSkillDialogButton({ className, skillButtonRef, selectedSkills, isBootstrapping, openSkillDialog, clearSkill, }: { className?: string; skillButtonRef?: RefObject; selectedSkills: Array<{ skill_id: string; title: string }>; isBootstrapping: boolean; openSkillDialog: () => void; clearSkill: (skillId?: string) => void; }) { const { t } = useI18n(); return (
{isBootstrapping ? ( {t.common.loading} ) : null} {!isBootstrapping && selectedSkills.length > 0 ? (
{ if (event.deltaY === 0) return; event.currentTarget.scrollLeft += event.deltaY; }} > {selectedSkills.map((skill, index) => ( {skill.title} {/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */} ))}
) : null}
); } // 附件预览栏 - 在输入框上方显示 function AttachmentPreviewBar({ references, threadId, onRemoveReference, }: { references: PromptInputReference[]; threadId: string; onRemoveReference: (reference: PromptInputReference) => void; }) { const { t } = useI18n(); const referenceSourceLabels = { artifact: t.inputBox.referenceSourceArtifact, upload: t.inputBox.referenceSourceUpload, } as const; const attachments = usePromptInputAttachments(); const hasReferences = references.length > 0; const hasAttachmentFiles = attachments.files.length > 0; if (!hasAttachmentFiles && !hasReferences) { return null; } return (
{hasAttachmentFiles && ( {(attachment) => } )} {hasReferences && (
{references.map((reference) => { const referenceUrl = threadId && reference.path ? urlOfArtifact({ filepath: reference.path, threadId, }) : null; const filename = reference.filename ?? "reference"; const imageMatch = /\.(png|jpe?g|gif|webp|bmp|svg)$/i.exec(filename); const extension = imageMatch?.[1]?.toLowerCase(); const mediaType = extension ? extension === "jpg" ? "image/jpeg" : extension === "svg" ? "image/svg+xml" : `image/${extension}` : "application/octet-stream"; return ( onRemoveReference(reference)} title={`${referenceSourceLabels[reference.ref_source]}${reference.path ? ` · ${getPathTail(reference.path)}` : ""}`} /> ); })}
)}
); } // ExtraHeader 容器 - 有附件时上浮 function ExtraHeaderContainer({ hasAttachments, children, }: { hasAttachments: boolean; children: React.ReactNode; }) { return (
{children}
); }