From fa3e6b6e84a0f2eb2bcf813a8b12a61c801c26e3 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 3 Jun 2026 15:01:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=89=A5=E7=A6=BB=EF=BC=8C=E9=81=BF=E5=85=8D=E5=8F=97=E9=95=BF?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=88=97=E8=A1=A8=E6=B8=B2=E6=9F=93=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=EF=BC=9B=E8=A6=86=E5=86=99=E6=BB=9A=E5=8A=A8=E6=9D=A1?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8A=A8=E4=BD=9C=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=8B=89=E5=88=B0=E6=9C=80=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentComposer.tsx | 58 ++++++++++++++------ src/components/chat/AgentWorkspace.tsx | 6 +++ src/components/chat/GlobalChatbox.tsx | 73 +++++++++++++++----------- 3 files changed, 90 insertions(+), 47 deletions(-) diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index 4dd3fbf..f9cad22 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -28,44 +28,65 @@ import BoltRounded from "@mui/icons-material/BoltRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import type { AgentModel } from "@/lib/chatStream"; +export type AgentComposerHandle = { + focus: () => void; + clear: () => void; + append: (text: string) => void; + setValue: (value: string) => void; + getValue: () => string; +}; + type AgentComposerProps = { - input: string; - inputRef: React.RefObject; isHydrating?: boolean; isStreaming: boolean; isListening: boolean; isSttSupported: boolean; presets: string[]; - onInputChange: (value: string) => void; - onSend: () => void; + onSend: (prompt: string) => void; onAbort: () => void; onStartListening: () => void; onStopListening: () => void; - onPresetSelect: (prompt: string) => void; selectedModel: AgentModel; onModelChange: (model: AgentModel) => void; }; -export const AgentComposer = ({ - input, - inputRef, +export const AgentComposer = React.forwardRef(function AgentComposer({ isHydrating = false, isStreaming, isListening, isSttSupported, presets, - onInputChange, onSend, onAbort, onStartListening, onStopListening, - onPresetSelect, selectedModel, onModelChange, -}: AgentComposerProps) => { +}, ref) { const theme = useTheme(); - const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; + const inputRef = React.useRef(null); + const [input, setInput] = React.useState(""); const [isPresetOpen, setIsPresetOpen] = React.useState(false); + const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; + + React.useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + clear: () => setInput(""), + append: (text: string) => setInput((prev) => prev + text), + setValue: (value: string) => setInput(value), + getValue: () => input, + }), + [input], + ); + + const handleSend = React.useCallback(() => { + const prompt = input.trim(); + if (!prompt || isStreaming || isHydrating) return; + setInput(""); + onSend(prompt); + }, [input, isHydrating, isStreaming, onSend]); return ( @@ -121,8 +142,11 @@ export const AgentComposer = ({ size="medium" clickable onClick={() => { - onPresetSelect(prompt); + setInput(prompt); setIsPresetOpen(false); + window.setTimeout(() => { + inputRef.current?.focus(); + }, 0); }} sx={{ height: 32, @@ -165,11 +189,11 @@ export const AgentComposer = ({ onInputChange(event.target.value)} + onChange={(event) => setInput(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); - onSend(); + handleSend(); } }} placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} @@ -362,7 +386,7 @@ export const AgentComposer = ({ ); -}; +}); diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 06eafb9..c85b387 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -23,6 +23,8 @@ type AgentWorkspaceProps = { branchGroups: BranchGroup[]; branchTransition: BranchTransition | null; isStreaming: boolean; + scrollContainerRef: React.RefObject; + onScroll: React.UIEventHandler; bottomRef: React.RefObject; speakingMessageId: string | null; speechState: SpeechState; @@ -155,6 +157,8 @@ export const AgentWorkspace = ({ branchGroups, branchTransition, isStreaming, + scrollContainerRef, + onScroll, bottomRef, speakingMessageId, speechState, @@ -216,6 +220,8 @@ export const AgentWorkspace = ({ return ( = ({ open, onClose }) => { - const [input, setInput] = useState(""); const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); @@ -31,8 +30,10 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { ); const bottomRef = useRef(null); - const inputRef = useRef(null); + const workspaceRef = useRef(null); + const composerRef = useRef(null); const hasResetForOpenRef = useRef(false); + const shouldAutoScrollRef = useRef(true); const theme = useTheme(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -47,7 +48,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { } = useSpeechSynthesis(); const handleSpeechResult = useCallback((text: string) => { - setInput((prev) => prev + text); + composerRef.current?.append(text); }, []); const { @@ -83,9 +84,22 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { getModel: () => selectedModel, }); + const syncAutoScrollState = useCallback(() => { + const container = workspaceRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + shouldAutoScrollRef.current = distanceFromBottom <= 120; + }, []); + + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + bottomRef.current?.scrollIntoView({ behavior }); + }, []); + useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, isStreaming]); + if (!shouldAutoScrollRef.current) return; + scrollToBottom(isStreaming ? "auto" : "smooth"); + }, [isStreaming, messages, scrollToBottom]); useEffect(() => { if (!open) { @@ -96,38 +110,33 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { hasResetForOpenRef.current = true; const timer = window.setTimeout(() => { + shouldAutoScrollRef.current = true; createSession(); - setInput(""); + composerRef.current?.clear(); setIsHistoryOpen(false); - inputRef.current?.focus(); - bottomRef.current?.scrollIntoView({ behavior: "auto" }); + composerRef.current?.focus(); + scrollToBottom("auto"); }, 0); return () => window.clearTimeout(timer); - }, [createSession, isHydrating, open]); + }, [createSession, isHydrating, open, scrollToBottom]); - const handleSend = useCallback(() => { - const prompt = input.trim(); - if (!prompt || isStreaming) return; - setInput(""); + const handleSend = useCallback((prompt: string) => { + if (isStreaming) return; + shouldAutoScrollRef.current = true; void sendPrompt(prompt); - }, [input, isStreaming, sendPrompt]); - - const handlePresetPromptSelect = useCallback((prompt: string) => { - setInput(prompt); - window.setTimeout(() => { - inputRef.current?.focus(); - }, 0); - }, []); + }, [isStreaming, sendPrompt]); const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); + shouldAutoScrollRef.current = true; createSession(); - setInput(""); + composerRef.current?.clear(); window.setTimeout(() => { - inputRef.current?.focus(); + composerRef.current?.focus(); + scrollToBottom("auto"); }, 0); - }, [createSession, handleStopSpeech, stopListening]); + }, [createSession, handleStopSpeech, scrollToBottom, stopListening]); const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); @@ -135,12 +144,17 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const handleSelectSession = useCallback( (storageSessionId: string) => { - setInput(""); + shouldAutoScrollRef.current = true; + composerRef.current?.clear(); void switchSession(storageSessionId); }, [switchSession], ); + const handleWorkspaceScroll = useCallback(() => { + syncAutoScrollState(); + }, [syncAutoScrollState]); + const handleDeleteSession = useCallback( (storageSessionId: string) => { void removeSession(storageSessionId); @@ -320,6 +334,8 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { branchGroups={branchGroups} branchTransition={branchTransition} isStreaming={isStreaming} + scrollContainerRef={workspaceRef} + onScroll={handleWorkspaceScroll} bottomRef={bottomRef} speakingMessageId={speakingMessageId} speechState={speechState} @@ -334,19 +350,16 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { />