Compare commits
4 Commits
9761ade8d8
...
20ca410e0a
| Author | SHA1 | Date | |
|---|---|---|---|
| 20ca410e0a | |||
| 06a3f32d2d | |||
| fa3e6b6e84 | |||
| 888132a60f |
@@ -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<HTMLInputElement | null>;
|
||||
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<AgentComposerHandle, AgentComposerProps>(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<HTMLInputElement | HTMLTextAreaElement | null>(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 (
|
||||
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
||||
@@ -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 = ({
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
value={input}
|
||||
onChange={(event) => 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 = ({
|
||||
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
||||
<IconButton
|
||||
disabled={!canSend}
|
||||
onClick={onSend}
|
||||
onClick={handleSend}
|
||||
aria-label="发送"
|
||||
size="small"
|
||||
sx={{
|
||||
@@ -397,4 +421,4 @@ export const AgentComposer = ({
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -85,7 +85,12 @@ const formatToolTitle = (item: ChatProgress) => {
|
||||
return item.title;
|
||||
};
|
||||
|
||||
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
|
||||
type AgentProgressTimelineProps = {
|
||||
progress: ChatProgress[];
|
||||
isAborted?: boolean;
|
||||
};
|
||||
|
||||
const AgentProgressTimelineInner = ({ progress, isAborted }: AgentProgressTimelineProps) => {
|
||||
const theme = useTheme();
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
@@ -356,3 +361,12 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentProgressTimeline = React.memo(
|
||||
AgentProgressTimelineInner,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.progress === nextProps.progress &&
|
||||
prevProps.isAborted === nextProps.isAborted,
|
||||
);
|
||||
|
||||
AgentProgressTimeline.displayName = "AgentProgressTimeline";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -85,15 +85,21 @@ export const AgentTurn = React.memo(
|
||||
const [editDraft, setEditDraft] = React.useState(message.content);
|
||||
const rootMessageId = message.branchRootId ?? message.id;
|
||||
|
||||
const parsedAssistantSections =
|
||||
const parsedAssistantSections = useMemo(
|
||||
() =>
|
||||
!isUser && !isErrorMessage
|
||||
? parseAssistantMessageSections(message.content)
|
||||
: null;
|
||||
: null,
|
||||
[isErrorMessage, isUser, message.content],
|
||||
);
|
||||
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||
const contentSegments: ContentSegment[] =
|
||||
const contentSegments: ContentSegment[] = useMemo(
|
||||
() =>
|
||||
!isUser && !isErrorMessage
|
||||
? parseContentWithToolCalls(answerContent).segments
|
||||
: [{ type: "text", content: answerContent }];
|
||||
: [{ type: "text", content: answerContent }],
|
||||
[answerContent, isErrorMessage, isUser],
|
||||
);
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import { AgentWorkspace } from "./AgentWorkspace";
|
||||
import type { Message } from "./GlobalChatbox.types";
|
||||
|
||||
const renderCounts = new Map<string, number>();
|
||||
|
||||
jest.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt ?? ""} />,
|
||||
}));
|
||||
|
||||
jest.mock("framer-motion", () => ({
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("./GlobalChatbox.parts", () => ({
|
||||
TypingIndicator: () => <div>typing</div>,
|
||||
}));
|
||||
|
||||
jest.mock("./AgentTurn", () => ({
|
||||
AgentTurn: ({ message }: { message: Message }) => {
|
||||
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
||||
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("AgentWorkspace", () => {
|
||||
const defaultProps = {
|
||||
branchGroups: [],
|
||||
branchTransition: null,
|
||||
bottomRef: { current: null },
|
||||
speakingMessageId: null,
|
||||
speechState: "idle" as const,
|
||||
onSpeak: jest.fn(),
|
||||
onPauseSpeech: jest.fn(),
|
||||
onResumeSpeech: jest.fn(),
|
||||
onStopSpeech: jest.fn(),
|
||||
isTtsSupported: false,
|
||||
onRegenerate: jest.fn(),
|
||||
onEditResubmit: jest.fn(),
|
||||
onCycleBranch: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
renderCounts.clear();
|
||||
});
|
||||
|
||||
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
||||
const userMessage: Message = {
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: "question",
|
||||
};
|
||||
const assistantHistoryMessage: Message = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "stable answer",
|
||||
};
|
||||
const streamingMessage: Message = {
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
content: "partial",
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentWorkspace
|
||||
{...defaultProps}
|
||||
isStreaming
|
||||
messages={[userMessage, assistantHistoryMessage, streamingMessage]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const updatedStreamingMessage: Message = {
|
||||
...streamingMessage,
|
||||
content: "partial with more tokens",
|
||||
};
|
||||
|
||||
rerender(
|
||||
<AgentWorkspace
|
||||
{...defaultProps}
|
||||
isStreaming
|
||||
messages={[userMessage, assistantHistoryMessage, updatedStreamingMessage]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(renderCounts.get("user-1")).toBe(1);
|
||||
expect(renderCounts.get("assistant-1")).toBe(1);
|
||||
expect(renderCounts.get("assistant-2")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { AgentTurn } from "./AgentTurn";
|
||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||||
import type {
|
||||
BranchGroup,
|
||||
BranchState,
|
||||
BranchTransition,
|
||||
Message,
|
||||
SpeechState,
|
||||
@@ -36,6 +37,96 @@ type AgentWorkspaceProps = {
|
||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||
};
|
||||
|
||||
type TurnListProps = {
|
||||
messages: Message[];
|
||||
branchGroups: BranchGroup[];
|
||||
speakingMessageId: string | null;
|
||||
speechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onPauseSpeech: () => void;
|
||||
onResumeSpeech: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onRegenerate: () => void;
|
||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||
};
|
||||
|
||||
const sameMessages = (left: Message[], right: Message[]) =>
|
||||
left.length === right.length &&
|
||||
left.every((message, index) => message === right[index]);
|
||||
|
||||
const TurnListInner = ({
|
||||
messages,
|
||||
branchGroups,
|
||||
speakingMessageId,
|
||||
speechState,
|
||||
onSpeak,
|
||||
onPauseSpeech,
|
||||
onResumeSpeech,
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onRegenerate,
|
||||
onEditResubmit,
|
||||
onCycleBranch,
|
||||
}: TurnListProps) => {
|
||||
const branchStateByRootId = React.useMemo(() => {
|
||||
const next = new Map<string, BranchState>();
|
||||
branchGroups.forEach((group) => {
|
||||
if (group.branches.length > 1) {
|
||||
next.set(group.rootMessageId, {
|
||||
activeIndex: group.activeIndex,
|
||||
total: group.branches.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}, [branchGroups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message) => {
|
||||
const rootMessageId = message.branchRootId ?? message.id;
|
||||
return (
|
||||
<AgentTurn
|
||||
key={rootMessageId}
|
||||
message={message}
|
||||
branchState={branchStateByRootId.get(rootMessageId)}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TurnList = React.memo(
|
||||
TurnListInner,
|
||||
(prevProps, nextProps) =>
|
||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||
prevProps.branchGroups === nextProps.branchGroups &&
|
||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||
prevProps.speechState === nextProps.speechState &&
|
||||
prevProps.onSpeak === nextProps.onSpeak &&
|
||||
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
|
||||
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||||
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||||
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||
prevProps.onRegenerate === nextProps.onRegenerate &&
|
||||
prevProps.onEditResubmit === nextProps.onEditResubmit &&
|
||||
prevProps.onCycleBranch === nextProps.onCycleBranch,
|
||||
);
|
||||
|
||||
TurnList.displayName = "TurnList";
|
||||
|
||||
const EmptyState = () => {
|
||||
const theme = useTheme();
|
||||
const capabilities = [
|
||||
@@ -182,37 +273,12 @@ export const AgentWorkspace = ({
|
||||
const transitionMessages = branchTransition
|
||||
? messages.slice(branchTransition.parentCount)
|
||||
: [];
|
||||
|
||||
const renderTurn = (message: Message) => {
|
||||
const rootMessageId = message.branchRootId ?? message.id;
|
||||
const branchGroup = branchGroups.find(
|
||||
(group) => group.rootMessageId === rootMessageId,
|
||||
);
|
||||
|
||||
return (
|
||||
<AgentTurn
|
||||
key={rootMessageId}
|
||||
message={message}
|
||||
branchState={
|
||||
branchGroup && branchGroup.branches.length > 1
|
||||
? {
|
||||
activeIndex: branchGroup.activeIndex,
|
||||
total: branchGroup.branches.length,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||
onSpeak={onSpeak}
|
||||
onPause={onPauseSpeech}
|
||||
onResume={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const streamingMessage =
|
||||
!branchTransition && isStreaming && messages.at(-1)?.role === "assistant"
|
||||
? messages.at(-1)
|
||||
: undefined;
|
||||
const historyMessages =
|
||||
streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -232,7 +298,37 @@ export const AgentWorkspace = ({
|
||||
|
||||
{messages.length > 0 ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{stableMessages.map(renderTurn)}
|
||||
<TurnList
|
||||
messages={historyMessages}
|
||||
branchGroups={branchGroups}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
/>
|
||||
|
||||
{streamingMessage ? (
|
||||
<TurnList
|
||||
messages={[streamingMessage]}
|
||||
branchGroups={branchGroups}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{branchTransition ? (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
@@ -244,7 +340,20 @@ export const AgentWorkspace = ({
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
||||
>
|
||||
{transitionMessages.map(renderTurn)}
|
||||
<TurnList
|
||||
messages={transitionMessages}
|
||||
branchGroups={branchGroups}
|
||||
speakingMessageId={speakingMessageId}
|
||||
speechState={speechState}
|
||||
onSpeak={onSpeak}
|
||||
onPauseSpeech={onPauseSpeech}
|
||||
onResumeSpeech={onResumeSpeech}
|
||||
onStopSpeech={onStopSpeech}
|
||||
isTtsSupported={isTtsSupported}
|
||||
onRegenerate={onRegenerate}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onCycleBranch={onCycleBranch}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
) : null}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||
|
||||
import type { AgentModel } from "@/lib/chatStream";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { AgentComposer } from "./AgentComposer";
|
||||
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||
import { AgentHeader } from "./AgentHeader";
|
||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||
import { AgentWorkspace } from "./AgentWorkspace";
|
||||
@@ -22,7 +22,6 @@ import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||
|
||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [input, setInput] = useState("");
|
||||
const [width, setWidth] = useState(520);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
@@ -31,7 +30,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
);
|
||||
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||
const hasResetForOpenRef = useRef(false);
|
||||
const theme = useTheme();
|
||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||
@@ -47,7 +46,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
} = useSpeechSynthesis();
|
||||
|
||||
const handleSpeechResult = useCallback((text: string) => {
|
||||
setInput((prev) => prev + text);
|
||||
composerRef.current?.append(text);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
@@ -83,9 +82,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
getModel: () => selectedModel,
|
||||
});
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
bottomRef.current?.scrollIntoView({ behavior });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isStreaming]);
|
||||
scrollToBottom(isStreaming ? "auto" : "smooth");
|
||||
}, [isStreaming, messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -97,37 +100,29 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
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;
|
||||
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();
|
||||
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,7 +130,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(storageSessionId: string) => {
|
||||
setInput("");
|
||||
composerRef.current?.clear();
|
||||
void switchSession(storageSessionId);
|
||||
},
|
||||
[switchSession],
|
||||
@@ -334,19 +329,16 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
/>
|
||||
|
||||
<AgentComposer
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
ref={composerRef}
|
||||
isHydrating={isHydrating}
|
||||
isStreaming={isStreaming}
|
||||
isListening={isListening}
|
||||
isSttSupported={isSttSupported}
|
||||
presets={PRESET_PROMPTS}
|
||||
onInputChange={setInput}
|
||||
onSend={handleSend}
|
||||
onAbort={abort}
|
||||
onStartListening={startListening}
|
||||
onStopListening={stopListening}
|
||||
onPresetSelect={handlePresetPromptSelect}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
/>
|
||||
|
||||
@@ -623,7 +623,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
||||
const calculationDate = selectedDate;
|
||||
const calculationTime = currentTime;
|
||||
const calculationDateStr = calculationDate.toISOString().split("T")[0];
|
||||
const calculationDateTime = currentTimeToDate(
|
||||
calculationDate,
|
||||
calculationTime
|
||||
);
|
||||
|
||||
setIsCalculating(true);
|
||||
// 显示处理中的通知
|
||||
@@ -635,8 +638,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
try {
|
||||
const body = {
|
||||
name: NETWORK_NAME,
|
||||
simulation_date: calculationDateStr, // YYYY-MM-DD
|
||||
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
|
||||
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||
duration: calculatedInterval,
|
||||
};
|
||||
|
||||
@@ -651,7 +653,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json().catch(() => null);
|
||||
|
||||
if (response.ok && result?.status === "success") {
|
||||
open?.({
|
||||
type: "success",
|
||||
message: "重新计算成功",
|
||||
@@ -660,9 +664,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
||||
clearCacheAndRefetch(calculationDate, calculationTime);
|
||||
setForceStyleAutoApplyVersion?.((prev) => prev + 1);
|
||||
} else {
|
||||
const errorMessage =
|
||||
result?.detail || result?.message || "重新计算失败";
|
||||
open?.({
|
||||
type: "error",
|
||||
message: "重新计算失败",
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user