输入框状态剥离,避免受长信息列表渲染影响;覆写滚动条状态动作,不再强制拉到最底
This commit is contained in:
@@ -28,44 +28,65 @@ import BoltRounded from "@mui/icons-material/BoltRounded";
|
|||||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
import type { AgentModel } from "@/lib/chatStream";
|
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 = {
|
type AgentComposerProps = {
|
||||||
input: string;
|
|
||||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
||||||
isHydrating?: boolean;
|
isHydrating?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isSttSupported: boolean;
|
isSttSupported: boolean;
|
||||||
presets: string[];
|
presets: string[];
|
||||||
onInputChange: (value: string) => void;
|
onSend: (prompt: string) => void;
|
||||||
onSend: () => void;
|
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
onStartListening: () => void;
|
onStartListening: () => void;
|
||||||
onStopListening: () => void;
|
onStopListening: () => void;
|
||||||
onPresetSelect: (prompt: string) => void;
|
|
||||||
selectedModel: AgentModel;
|
selectedModel: AgentModel;
|
||||||
onModelChange: (model: AgentModel) => void;
|
onModelChange: (model: AgentModel) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentComposer = ({
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||||
input,
|
|
||||||
inputRef,
|
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isListening,
|
isListening,
|
||||||
isSttSupported,
|
isSttSupported,
|
||||||
presets,
|
presets,
|
||||||
onInputChange,
|
|
||||||
onSend,
|
onSend,
|
||||||
onAbort,
|
onAbort,
|
||||||
onStartListening,
|
onStartListening,
|
||||||
onStopListening,
|
onStopListening,
|
||||||
onPresetSelect,
|
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
}: AgentComposerProps) => {
|
}, ref) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||||
|
const [input, setInput] = React.useState("");
|
||||||
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
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 (
|
return (
|
||||||
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
||||||
@@ -121,8 +142,11 @@ export const AgentComposer = ({
|
|||||||
size="medium"
|
size="medium"
|
||||||
clickable
|
clickable
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPresetSelect(prompt);
|
setInput(prompt);
|
||||||
setIsPresetOpen(false);
|
setIsPresetOpen(false);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -165,11 +189,11 @@ export const AgentComposer = ({
|
|||||||
<TextField
|
<TextField
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => onInputChange(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
||||||
@@ -362,7 +386,7 @@ export const AgentComposer = ({
|
|||||||
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
onClick={onSend}
|
onClick={handleSend}
|
||||||
aria-label="发送"
|
aria-label="发送"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -397,4 +421,4 @@ export const AgentComposer = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type AgentWorkspaceProps = {
|
|||||||
branchGroups: BranchGroup[];
|
branchGroups: BranchGroup[];
|
||||||
branchTransition: BranchTransition | null;
|
branchTransition: BranchTransition | null;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onScroll: React.UIEventHandler<HTMLDivElement>;
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
@@ -155,6 +157,8 @@ export const AgentWorkspace = ({
|
|||||||
branchGroups,
|
branchGroups,
|
||||||
branchTransition,
|
branchTransition,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
scrollContainerRef,
|
||||||
|
onScroll,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
speechState,
|
speechState,
|
||||||
@@ -216,6 +220,8 @@ export const AgentWorkspace = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={onScroll}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
|||||||
|
|
||||||
import type { AgentModel } from "@/lib/chatStream";
|
import type { AgentModel } from "@/lib/chatStream";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { AgentComposer } from "./AgentComposer";
|
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
import { AgentWorkspace } from "./AgentWorkspace";
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
@@ -22,7 +22,6 @@ import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
|||||||
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
@@ -31,8 +30,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const workspaceRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const hasResetForOpenRef = useRef(false);
|
const hasResetForOpenRef = useRef(false);
|
||||||
|
const shouldAutoScrollRef = useRef(true);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
} = useSpeechSynthesis();
|
} = useSpeechSynthesis();
|
||||||
|
|
||||||
const handleSpeechResult = useCallback((text: string) => {
|
const handleSpeechResult = useCallback((text: string) => {
|
||||||
setInput((prev) => prev + text);
|
composerRef.current?.append(text);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -83,9 +84,22 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
getModel: () => selectedModel,
|
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(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
if (!shouldAutoScrollRef.current) return;
|
||||||
}, [messages, isStreaming]);
|
scrollToBottom(isStreaming ? "auto" : "smooth");
|
||||||
|
}, [isStreaming, messages, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -96,38 +110,33 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
hasResetForOpenRef.current = true;
|
hasResetForOpenRef.current = true;
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
shouldAutoScrollRef.current = true;
|
||||||
createSession();
|
createSession();
|
||||||
setInput("");
|
composerRef.current?.clear();
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
inputRef.current?.focus();
|
composerRef.current?.focus();
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
scrollToBottom("auto");
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [createSession, isHydrating, open]);
|
}, [createSession, isHydrating, open, scrollToBottom]);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback((prompt: string) => {
|
||||||
const prompt = input.trim();
|
if (isStreaming) return;
|
||||||
if (!prompt || isStreaming) return;
|
shouldAutoScrollRef.current = true;
|
||||||
setInput("");
|
|
||||||
void sendPrompt(prompt);
|
void sendPrompt(prompt);
|
||||||
}, [input, isStreaming, sendPrompt]);
|
}, [isStreaming, sendPrompt]);
|
||||||
|
|
||||||
const handlePresetPromptSelect = useCallback((prompt: string) => {
|
|
||||||
setInput(prompt);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNewConversation = useCallback(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
|
shouldAutoScrollRef.current = true;
|
||||||
createSession();
|
createSession();
|
||||||
setInput("");
|
composerRef.current?.clear();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
composerRef.current?.focus();
|
||||||
|
scrollToBottom("auto");
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [createSession, handleStopSpeech, stopListening]);
|
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
||||||
|
|
||||||
const handleHistoryToggle = useCallback(() => {
|
const handleHistoryToggle = useCallback(() => {
|
||||||
setIsHistoryOpen((prev) => !prev);
|
setIsHistoryOpen((prev) => !prev);
|
||||||
@@ -135,12 +144,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(storageSessionId: string) => {
|
(storageSessionId: string) => {
|
||||||
setInput("");
|
shouldAutoScrollRef.current = true;
|
||||||
|
composerRef.current?.clear();
|
||||||
void switchSession(storageSessionId);
|
void switchSession(storageSessionId);
|
||||||
},
|
},
|
||||||
[switchSession],
|
[switchSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleWorkspaceScroll = useCallback(() => {
|
||||||
|
syncAutoScrollState();
|
||||||
|
}, [syncAutoScrollState]);
|
||||||
|
|
||||||
const handleDeleteSession = useCallback(
|
const handleDeleteSession = useCallback(
|
||||||
(storageSessionId: string) => {
|
(storageSessionId: string) => {
|
||||||
void removeSession(storageSessionId);
|
void removeSession(storageSessionId);
|
||||||
@@ -320,6 +334,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
branchGroups={branchGroups}
|
branchGroups={branchGroups}
|
||||||
branchTransition={branchTransition}
|
branchTransition={branchTransition}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
scrollContainerRef={workspaceRef}
|
||||||
|
onScroll={handleWorkspaceScroll}
|
||||||
bottomRef={bottomRef}
|
bottomRef={bottomRef}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
speechState={speechState}
|
speechState={speechState}
|
||||||
@@ -334,19 +350,16 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentComposer
|
<AgentComposer
|
||||||
input={input}
|
ref={composerRef}
|
||||||
inputRef={inputRef}
|
|
||||||
isHydrating={isHydrating}
|
isHydrating={isHydrating}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
isSttSupported={isSttSupported}
|
isSttSupported={isSttSupported}
|
||||||
presets={PRESET_PROMPTS}
|
presets={PRESET_PROMPTS}
|
||||||
onInputChange={setInput}
|
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onAbort={abort}
|
onAbort={abort}
|
||||||
onStartListening={startListening}
|
onStartListening={startListening}
|
||||||
onStopListening={stopListening}
|
onStopListening={stopListening}
|
||||||
onPresetSelect={handlePresetPromptSelect}
|
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user