Compare commits
4 Commits
| 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 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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -85,7 +85,12 @@ const formatToolTitle = (item: ChatProgress) => {
|
|||||||
return item.title;
|
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 theme = useTheme();
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
@@ -356,3 +361,12 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
|
|||||||
</Box>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
@@ -85,15 +85,21 @@ export const AgentTurn = React.memo(
|
|||||||
const [editDraft, setEditDraft] = React.useState(message.content);
|
const [editDraft, setEditDraft] = React.useState(message.content);
|
||||||
const rootMessageId = message.branchRootId ?? message.id;
|
const rootMessageId = message.branchRootId ?? message.id;
|
||||||
|
|
||||||
const parsedAssistantSections =
|
const parsedAssistantSections = useMemo(
|
||||||
|
() =>
|
||||||
!isUser && !isErrorMessage
|
!isUser && !isErrorMessage
|
||||||
? parseAssistantMessageSections(message.content)
|
? parseAssistantMessageSections(message.content)
|
||||||
: null;
|
: null,
|
||||||
|
[isErrorMessage, isUser, message.content],
|
||||||
|
);
|
||||||
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
const contentSegments: ContentSegment[] =
|
const contentSegments: ContentSegment[] = useMemo(
|
||||||
|
() =>
|
||||||
!isUser && !isErrorMessage
|
!isUser && !isErrorMessage
|
||||||
? parseContentWithToolCalls(answerContent).segments
|
? parseContentWithToolCalls(answerContent).segments
|
||||||
: [{ type: "text", content: answerContent }];
|
: [{ type: "text", content: answerContent }],
|
||||||
|
[answerContent, isErrorMessage, isUser],
|
||||||
|
);
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
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 { TypingIndicator } from "./GlobalChatbox.parts";
|
||||||
import type {
|
import type {
|
||||||
BranchGroup,
|
BranchGroup,
|
||||||
|
BranchState,
|
||||||
BranchTransition,
|
BranchTransition,
|
||||||
Message,
|
Message,
|
||||||
SpeechState,
|
SpeechState,
|
||||||
@@ -36,6 +37,96 @@ type AgentWorkspaceProps = {
|
|||||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
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 EmptyState = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
@@ -182,37 +273,12 @@ export const AgentWorkspace = ({
|
|||||||
const transitionMessages = branchTransition
|
const transitionMessages = branchTransition
|
||||||
? messages.slice(branchTransition.parentCount)
|
? messages.slice(branchTransition.parentCount)
|
||||||
: [];
|
: [];
|
||||||
|
const streamingMessage =
|
||||||
const renderTurn = (message: Message) => {
|
!branchTransition && isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
const rootMessageId = message.branchRootId ?? message.id;
|
? messages.at(-1)
|
||||||
const branchGroup = branchGroups.find(
|
: undefined;
|
||||||
(group) => group.rootMessageId === rootMessageId,
|
const historyMessages =
|
||||||
);
|
streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages;
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -232,7 +298,37 @@ export const AgentWorkspace = ({
|
|||||||
|
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<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 ? (
|
{branchTransition ? (
|
||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
@@ -244,7 +340,20 @@ export const AgentWorkspace = ({
|
|||||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 16 }}
|
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>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -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,7 +30,7 @@ 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 composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const hasResetForOpenRef = useRef(false);
|
const hasResetForOpenRef = useRef(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
@@ -47,7 +46,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 +82,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
getModel: () => selectedModel,
|
getModel: () => selectedModel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
scrollToBottom(isStreaming ? "auto" : "smooth");
|
||||||
}, [messages, isStreaming]);
|
}, [isStreaming, messages, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -97,37 +100,29 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
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;
|
|
||||||
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();
|
||||||
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,7 +130,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(storageSessionId: string) => {
|
(storageSessionId: string) => {
|
||||||
setInput("");
|
composerRef.current?.clear();
|
||||||
void switchSession(storageSessionId);
|
void switchSession(storageSessionId);
|
||||||
},
|
},
|
||||||
[switchSession],
|
[switchSession],
|
||||||
@@ -334,19 +329,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -623,7 +623,10 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
// 提前提取日期和时间值,避免异步操作期间被时间轴拖动改变
|
||||||
const calculationDate = selectedDate;
|
const calculationDate = selectedDate;
|
||||||
const calculationTime = currentTime;
|
const calculationTime = currentTime;
|
||||||
const calculationDateStr = calculationDate.toISOString().split("T")[0];
|
const calculationDateTime = currentTimeToDate(
|
||||||
|
calculationDate,
|
||||||
|
calculationTime
|
||||||
|
);
|
||||||
|
|
||||||
setIsCalculating(true);
|
setIsCalculating(true);
|
||||||
// 显示处理中的通知
|
// 显示处理中的通知
|
||||||
@@ -635,8 +638,7 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
name: NETWORK_NAME,
|
name: NETWORK_NAME,
|
||||||
simulation_date: calculationDateStr, // YYYY-MM-DD
|
start_time: dayjs(calculationDateTime).format("YYYY-MM-DDTHH:mm:ssZ"),
|
||||||
start_time: `${formatTime(calculationTime)}:00`, // HH:MM:00
|
|
||||||
duration: calculatedInterval,
|
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?.({
|
open?.({
|
||||||
type: "success",
|
type: "success",
|
||||||
message: "重新计算成功",
|
message: "重新计算成功",
|
||||||
@@ -660,9 +664,11 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
clearCacheAndRefetch(calculationDate, calculationTime);
|
clearCacheAndRefetch(calculationDate, calculationTime);
|
||||||
setForceStyleAutoApplyVersion?.((prev) => prev + 1);
|
setForceStyleAutoApplyVersion?.((prev) => prev + 1);
|
||||||
} else {
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
result?.detail || result?.message || "重新计算失败";
|
||||||
open?.({
|
open?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "重新计算失败",
|
message: errorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user