"use client"; import type { ChatStatus } from "ai"; import { BarChart3Icon, BrainCircuitIcon, BookMarkedIcon, CheckIcon, ClipboardCheckIcon, FileTextIcon, FolderTreeIcon, GraduationCapIcon, LightbulbIcon, MessageCircleQuestionIcon, PaperclipIcon, PlusIcon, RefreshCwIcon, ShrinkIcon, SparklesIcon, RocketIcon, XIcon, ZapIcon, } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, } from "react"; import { PromptInput, PromptInputActionMenu, PromptInputActionMenuContent, PromptInputActionMenuItem, PromptInputActionMenuTrigger, PromptInputAttachment, PromptInputAttachments, PromptInputBody, PromptInputButton, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, PromptInputTools, usePromptInputAttachments, usePromptInputController, type PromptInputMessage, } 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 { DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; import type { Model } from "@/core/models/types"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { AgentSelector } from "@/components/workspace/agents/agent-selector"; import { ModelSelector, ModelSelectorContent, ModelSelectorInput, ModelSelectorItem, ModelSelectorList, ModelSelectorName, ModelSelectorTrigger, } from "../ai-elements/model-selector"; import { Suggestion, Suggestions } from "../ai-elements/suggestion"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; import { useUserPreferences } from "@/core/user-preferences"; import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings, } from "@/core/settings"; import { CustomPromptBar } from "./custom-prompt-menu"; import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; type InputMode = "flash" | "thinking" | "pro" | "ultra"; const COMPACT_QUICK_STARTS = [ { id: "report", title: "撰写报告", content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。", icon: FileTextIcon, }, { id: "knowledge", title: "知识问答", content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。", icon: MessageCircleQuestionIcon, }, { id: "planning", title: "任务规划", content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。", icon: ClipboardCheckIcon, }, { id: "analysis", title: "数据分析", content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。", icon: BarChart3Icon, }, { id: "solution", title: "方案策划", content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。", icon: LightbulbIcon, }, { id: "summary", title: "内容总结", content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。", icon: BookMarkedIcon, }, { id: "research", title: "调研提纲", content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。", icon: GraduationCapIcon, }, { id: "polish", title: "文案润色", content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。", icon: SparklesIcon, }, ] as const; function getResolvedMode( mode: InputMode | undefined, supportsThinking: boolean, ): InputMode { if (!supportsThinking && mode !== "flash") { return "flash"; } if (mode) { return mode; } return supportsThinking ? "pro" : "flash"; } export function InputBox({ className, disabled, autoFocus, status = "ready", context, extraHeader, isNewThread, threadId, initialValue, onContextChange, onFollowupsVisibilityChange, onSubmit, onStop, promptPrefix, agentSelector, knowledgeSelector, extraModels = [], assistantId, hideAdminRecommendedQuestions, compact, ...props }: Omit, "onSubmit"> & { assistantId?: string | null; status?: ChatStatus; /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ compact?: boolean; disabled?: boolean; context: Omit< AgentThreadContext, "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" > & { mode: "flash" | "thinking" | "pro" | "ultra" | undefined; reasoning_effort?: "minimal" | "low" | "medium" | "high"; writing_mode?: boolean; memory_injection_enabled?: boolean; }; extraHeader?: React.ReactNode; isNewThread?: boolean; /** Suppress the global admin "recommended questions" row. The agent chat page * sets this so only the agent's own common questions surface on its home. */ hideAdminRecommendedQuestions?: boolean; threadId: string; initialValue?: string; onContextChange?: ( context: Omit< AgentThreadContext, "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" > & { mode: "flash" | "thinking" | "pro" | "ultra" | undefined; reasoning_effort?: "minimal" | "low" | "medium" | "high"; writing_mode?: boolean; memory_injection_enabled?: boolean; }, ) => void; onFollowupsVisibilityChange?: (visible: boolean) => void; onSubmit?: (message: PromptInputMessage) => void; onStop?: () => void; /** * Optional toggle rendered next to the model selector. Used for the * admin-configurable prompt-prefix switch on the chat input. Omitting the * prop hides the button entirely. */ promptPrefix?: { label: string; on: boolean; onToggle: () => void; }; /** * Optional agent picker shown alongside the model selector. Only the new-chat * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the * thread is already bound to one agent. */ agentSelector?: { selectedAgentId: string | null; onAgentChange: (agentId: string | null) => void; }; /** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */ knowledgeSelector?: ReactNode; /** Page-scoped model choices implemented outside the DeerFlow model API. */ extraModels?: Model[]; }) { const { t } = useI18n(); const [localSettings, setLocalSettings] = useLocalSettings(); const searchParams = useSearchParams(); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); const singleRoundRagActive = context.execution_profile === "single_round_rag"; const multiRoundRagActive = context.execution_profile === "multi_round_rag"; const frontendRagActive = singleRoundRagActive || multiRoundRagActive; const modelOptions = useMemo(() => { return singleRoundRagActive ? extraModels : models; }, [extraModels, models, singleRoundRagActive]); const { thread, isMock } = useThread(); const { textInput } = usePromptInputController(); const promptRootRef = useRef(null); const [followups, setFollowups] = useState([]); const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false); const lastGeneratedForAiIdRef = useRef(null); const wasStreamingRef = useRef(false); const [confirmOpen, setConfirmOpen] = useState(false); const [compactPromptOrder, setCompactPromptOrder] = useState(0); const [pendingSuggestion, setPendingSuggestion] = useState( null, ); const { questions: recommendedQuestions } = useRecommendedQuestions(); const userPreferences = useUserPreferences(); const showAdminRecommendedQuestions = !hideAdminRecommendedQuestions && !userPreferences.hide_admin_prompt_prefix && recommendedQuestions.length > 0; const compactQuickStarts = useMemo(() => { if (showAdminRecommendedQuestions) { return recommendedQuestions.map((configured, index) => { const fallback = COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!; return { ...fallback, id: configured.id, title: desensitize(configured.title) || `提示词 ${index + 1}`, content: configured.content || fallback.content, }; }); } return [...COMPACT_QUICK_STARTS]; }, [recommendedQuestions, showAdminRecommendedQuestions]); const orderedCompactQuickStarts = useMemo(() => { if (compactQuickStarts.length < 2) return compactQuickStarts; const offset = compactPromptOrder % compactQuickStarts.length; return [ ...compactQuickStarts.slice(offset), ...compactQuickStarts.slice(0, offset), ]; }, [compactPromptOrder, compactQuickStarts]); useEffect(() => { // Multi-round RAG is a frontend execution profile, not a model option. // Do not let the normal model fallback clear it on every render. if (multiRoundRagActive) { return; } if (modelOptions.length === 0) { return; } const currentModel = modelOptions.find((m) => m.name === context.model_name); const fallbackModel = currentModel ?? modelOptions[0]!; const supportsThinking = fallbackModel.supports_thinking ?? false; const nextModelName = fallbackModel.name; const nextMode = getResolvedMode(context.mode, supportsThinking); if ( context.model_name === nextModelName && context.mode === nextMode && context.execution_profile === fallbackModel.execution_profile ) { return; } onContextChange?.({ ...context, model_name: nextModelName, mode: nextMode, execution_profile: fallbackModel.execution_profile, }); }, [context, modelOptions, multiRoundRagActive, onContextChange]); const selectedModel = useMemo(() => { if (modelOptions.length === 0) { return undefined; } return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0]; }, [context.model_name, modelOptions]); const resolvedModelName = selectedModel?.name; const selectedSingleRoundRag = singleRoundRagActive; const supportThinking = useMemo( () => selectedModel?.supports_thinking ?? false, [selectedModel], ); const supportReasoningEffort = useMemo( () => selectedModel?.supports_reasoning_effort ?? false, [selectedModel], ); const handleModelSelect = useCallback( (model_name: string) => { const model = modelOptions.find((m) => m.name === model_name); if (!model) { return; } onContextChange?.({ ...context, model_name, mode: getResolvedMode(context.mode, model.supports_thinking ?? false), reasoning_effort: context.reasoning_effort, execution_profile: model.execution_profile, }); setModelDialogOpen(false); }, [onContextChange, context, modelOptions], ); const handleModeSelect = useCallback( (mode: InputMode) => { const ordinaryModel = models.find((model) => model.name === context.model_name) ?? models[0]; onContextChange?.({ ...context, model_name: ordinaryModel?.name ?? context.model_name, mode: getResolvedMode( mode, ordinaryModel?.supports_thinking ?? supportThinking, ), reasoning_effort: mode === "ultra" ? "high" : mode === "pro" ? "medium" : mode === "thinking" ? "low" : "minimal", execution_profile: undefined, }); }, [onContextChange, context, models, supportThinking], ); const handleSingleRoundRagSelect = useCallback(() => { const ragModel = extraModels.find((model) => model.name === context.model_name) ?? extraModels[0]; if (!ragModel) { return; } onContextChange?.({ ...context, model_name: ragModel.name, mode: getResolvedMode( context.mode, ragModel.supports_thinking ?? false, ), execution_profile: "single_round_rag", }); }, [context, extraModels, onContextChange]); const handleMultiRoundRagSelect = useCallback(() => { onContextChange?.({ ...context, execution_profile: "multi_round_rag", }); }, [context, onContextChange]); const handleReasoningEffortSelect = useCallback( (effort: "minimal" | "low" | "medium" | "high") => { onContextChange?.({ ...context, reasoning_effort: effort, }); }, [onContextChange, context], ); const handleWritingModeToggle = useCallback(() => { onContextChange?.({ ...context, writing_mode: !context.writing_mode, }); }, [context, onContextChange]); const artifactDirectoryModeEnabled = localSettings.display.artifactDirectoryModeEnabled ?? false; const handleArtifactDirectoryModeToggle = useCallback(() => { setLocalSettings("display", { artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled, }); }, [artifactDirectoryModeEnabled, setLocalSettings]); const memoryInjectionEnabled = context.memory_injection_enabled ?? true; const handleMemoryInjectionToggle = useCallback(() => { onContextChange?.({ ...context, memory_injection_enabled: !memoryInjectionEnabled, }); }, [context, memoryInjectionEnabled, onContextChange]); const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; const handleRagModeToggle = useCallback(() => { if (ragModeLocked) { return; } setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); }, [ragModeEnabled, ragModeLocked, setLocalSettings]); const contextCompressionEnabled = localSettings.display.contextCompressionEnabled ?? false; const handleContextCompressionToggle = useCallback(() => { setLocalSettings("display", { contextCompressionEnabled: !contextCompressionEnabled, }); }, [contextCompressionEnabled, setLocalSettings]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { if (status === "streaming") { onStop?.(); return; } if (!message.text) { return; } setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); if (ragModeEnabled) { lockThreadRagMode(threadId); } // Guard against submitting before the initial model auto-selection // effect has flushed thread settings to storage/state. if (resolvedModelName && context.model_name !== resolvedModelName) { onContextChange?.({ ...context, model_name: resolvedModelName, mode: getResolvedMode( context.mode, selectedModel?.supports_thinking ?? false, ), execution_profile: selectedModel?.execution_profile, }); setTimeout(() => onSubmit?.(message), 0); return; } onSubmit?.(message); }, [ context, onContextChange, onSubmit, onStop, resolvedModelName, ragModeEnabled, selectedModel?.supports_thinking, selectedModel?.execution_profile, status, threadId, ], ); const requestFormSubmit = useCallback(() => { const form = promptRootRef.current?.querySelector("form"); form?.requestSubmit(); }, []); 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) { setConfirmOpen(false); return; } textInput.setInput(pendingSuggestion); setFollowupsHidden(true); setConfirmOpen(false); setPendingSuggestion(null); setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); const confirmAppendAndSend = useCallback(() => { if (!pendingSuggestion) { setConfirmOpen(false); return; } const current = (textInput.value ?? "").trim(); const next = current ? `${current}\n${pendingSuggestion}` : pendingSuggestion; textInput.setInput(next); setFollowupsHidden(true); setConfirmOpen(false); setPendingSuggestion(null); setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); const showFollowups = !disabled && !isNewThread && !selectedSingleRoundRag && !followupsHidden && (followupsLoading || followups.length > 0); const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange); useEffect(() => { followupsVisibilityChangeRef.current = onFollowupsVisibilityChange; }, [onFollowupsVisibilityChange]); useEffect(() => { followupsVisibilityChangeRef.current?.(showFollowups); }, [showFollowups]); useEffect(() => { return () => followupsVisibilityChangeRef.current?.(false); }, []); useEffect(() => { const streaming = status === "streaming"; const wasStreaming = wasStreamingRef.current; wasStreamingRef.current = streaming; if (!wasStreaming || streaming) { return; } if (disabled || isMock || selectedSingleRoundRag) { return; } const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m)); const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai"); const lastAiId = lastAi?.id ?? null; if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { return; } lastGeneratedForAiIdRef.current = lastAiId; const recent = visibleMessages .filter((m) => m.type === "human" || m.type === "ai") .map((m) => { const role = m.type === "human" ? "user" : "assistant"; const content = textOfMessage(m) ?? ""; return { role, content }; }) .filter((m) => m.content.trim().length > 0) .slice(-6); if (recent.length === 0) { return; } const controller = new AbortController(); setFollowupsHidden(false); setFollowupsLoading(true); setFollowups([]); apiFetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: recent, n: 3, model_name: context.model_name ?? undefined, }), signal: controller.signal, }) .then(async (res) => { if (!res.ok) { return { suggestions: [] as string[] }; } return (await res.json()) as { suggestions?: string[] }; }) .then((data) => { const suggestions = (data.suggestions ?? []) .map((s) => (typeof s === "string" ? s.trim() : "")) .filter((s) => s.length > 0) .slice(0, 5); setFollowups(suggestions); }) .catch(() => { setFollowups([]); }) .finally(() => { setFollowupsLoading(false); }); return () => controller.abort(); }, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]); return (
{extraHeader && (
{extraHeader}
)} {isNewThread && searchParams.get("mode") !== "skill" && compact && (
{orderedCompactQuickStarts.map((item) => ( ))}
)} {isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
{recommendedQuestions.map((q) => ( ))}
)} {showFollowups && (
{followupsLoading ? (
{t.inputBox.followupLoading}
) : ( {followups.map((s) => ( handleFollowupClick(s)} /> ))} )}
)}
{isNewThread && ( { const current = textInput.value?.trim() ?? ""; textInput.setInput(current ? `${current}\n${content}` : content); setTimeout(() => { document .querySelector("textarea[name='message']") ?.focus(); }, 0); }} /> )}
{(attachment) => } {/* TODO: Add more connectors here */} {!frontendRagActive && ( )} {!frontendRagActive && {context.writing_mode && ( )} 写作 } {!frontendRagActive && {artifactDirectoryModeEnabled && ( )} 文件 } {!frontendRagActive && {memoryInjectionEnabled && ( )} 记忆 } {!frontendRagActive && {ragModeEnabled && ( )} 参考文献 } {!frontendRagActive && {contextCompressionEnabled && ( )} 上下文压缩 }
{frontendRagActive ? ( ) : context.mode === "flash" ? ( ) : context.mode === "thinking" ? ( ) : context.mode === "pro" ? ( ) : context.mode === "ultra" ? ( ) : null}
{frontendRagActive ? multiRoundRagActive ? "多轮 RAG" : "单轮 RAG" : (context.mode === "flash" && t.inputBox.flashMode) || (context.mode === "thinking" && t.inputBox.reasoningMode) || (context.mode === "pro" && t.inputBox.proMode) || (context.mode === "ultra" && t.inputBox.ultraMode)}
{t.inputBox.mode} handleModeSelect("flash")} >
{t.inputBox.flashMode}
{t.inputBox.flashModeDescription}
{!frontendRagActive && context.mode === "flash" ? ( ) : (
)} {supportThinking && ( handleModeSelect("thinking")} >
{t.inputBox.reasoningMode}
{t.inputBox.reasoningModeDescription}
{!frontendRagActive && context.mode === "thinking" ? ( ) : (
)} )} handleModeSelect("pro")} >
{t.inputBox.proMode}
{t.inputBox.proModeDescription}
{!frontendRagActive && context.mode === "pro" ? ( ) : (
)} handleModeSelect("ultra")} >
{t.inputBox.ultraMode}
{t.inputBox.ultraModeDescription}
{!frontendRagActive && context.mode === "ultra" ? ( ) : (
)} {extraModels.length > 0 && ( <>
单轮 RAG 问答
基于知识库检索生成答案,并展示思考内容与参考资料
{selectedSingleRoundRag ? ( ) : (
)} )}
多轮 RAG 问答
多次检索、展示过程与参考资料,适合复杂问题
{multiRoundRagActive ? ( ) : (
)} {!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
{t.inputBox.reasoningEffort}: {context.reasoning_effort === "minimal" && " " + t.inputBox.reasoningEffortMinimal} {context.reasoning_effort === "low" && " " + t.inputBox.reasoningEffortLow} {context.reasoning_effort === "medium" && " " + t.inputBox.reasoningEffortMedium} {context.reasoning_effort === "high" && " " + t.inputBox.reasoningEffortHigh}
{t.inputBox.reasoningEffort} handleReasoningEffortSelect("minimal")} >
{t.inputBox.reasoningEffortMinimal}
{t.inputBox.reasoningEffortMinimalDescription}
{context.reasoning_effort === "minimal" ? ( ) : (
)} handleReasoningEffortSelect("low")} >
{t.inputBox.reasoningEffortLow}
{t.inputBox.reasoningEffortLowDescription}
{context.reasoning_effort === "low" ? ( ) : (
)} handleReasoningEffortSelect("medium")} >
{t.inputBox.reasoningEffortMedium}
{t.inputBox.reasoningEffortMediumDescription}
{context.reasoning_effort === "medium" || !context.reasoning_effort ? ( ) : (
)} handleReasoningEffortSelect("high")} >
{t.inputBox.reasoningEffortHigh}
{t.inputBox.reasoningEffortHighDescription}
{context.reasoning_effort === "high" ? ( ) : (
)} )} {!frontendRagActive && agentSelector && ( )} {!frontendRagActive && knowledgeSelector} {!frontendRagActive && promptPrefix && ( {promptPrefix.label} {promptPrefix.on && ( )} )} {!multiRoundRagActive &&
{selectedModel?.display_name}
{modelOptions.map((m) => ( handleModelSelect(m.name)} >
{m.display_name} {selectedSingleRoundRag ? `单轮知识库问答 · ${m.name}` : m.model}
{m.name === context.model_name ? ( ) : (
)} ))} } {!isNewThread && (
)}
{t.inputBox.followupConfirmTitle} {t.inputBox.followupConfirmDescription}
); } function SuggestionList() { const { t } = useI18n(); const { textInput } = usePromptInputController(); const handleSuggestionClick = useCallback( (prompt: string | undefined) => { if (!prompt) return; textInput.setInput(prompt); setTimeout(() => { const textarea = document.querySelector( "textarea[name='message']", ); if (textarea) { const selStart = prompt.indexOf("["); const selEnd = prompt.indexOf("]"); if (selStart !== -1 && selEnd !== -1) { textarea.setSelectionRange(selStart, selEnd + 1); textarea.focus(); } } }, 500); }, [textInput], ); return ( handleSuggestionClick(t.inputBox.surpriseMePrompt)} > {t.inputBox.surpriseMe} {t.inputBox.suggestions.map((suggestion) => ( handleSuggestionClick(suggestion.prompt)} /> ))} {t.inputBox.suggestionsCreate.map((suggestion, index) => "type" in suggestion && suggestion.type === "separator" ? ( ) : ( !("type" in suggestion) && ( handleSuggestionClick(suggestion.prompt)} > {suggestion.icon && } {suggestion.suggestion} ) ), )} ); } function AddAttachmentsButton({ className }: { className?: string }) { const { t } = useI18n(); const attachments = usePromptInputAttachments(); return ( attachments.openFileDialog()} > ); }