refactor: simplify chat fork flow
This commit is contained in:
@@ -23,17 +23,14 @@ import {
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||
import RefreshRounded from "@mui/icons-material/RefreshRounded";
|
||||
import EditRounded from "@mui/icons-material/EditRounded";
|
||||
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||
import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
|
||||
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
|
||||
import { TbArrowsSplit2 } from "react-icons/tb";
|
||||
import {
|
||||
parseAssistantMessageSections,
|
||||
parseContentWithToolCalls,
|
||||
type ContentSegment,
|
||||
} from "./chatMessageSections";
|
||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||
import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||
import { ChatInlineChart } from "./ChatInlineChart";
|
||||
@@ -45,7 +42,6 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
|
||||
import PauseRounded from "@mui/icons-material/PauseRounded";
|
||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||
import StopRounded from "@mui/icons-material/StopRounded";
|
||||
import SendRounded from "@mui/icons-material/SendRounded";
|
||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||
import TerminalRounded from "@mui/icons-material/TerminalRounded";
|
||||
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
||||
@@ -58,24 +54,34 @@ import type { PermissionReply } from "@/lib/chatStream";
|
||||
|
||||
type AgentTurnProps = {
|
||||
message: Message;
|
||||
branchState?: BranchState;
|
||||
messageSpeechState: SpeechState;
|
||||
onSpeak: (messageId: string, text: string) => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
onRegenerate: () => void;
|
||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||
onRegenerate: (messageId: string) => void;
|
||||
onCreateBranch: (messageId: string) => void;
|
||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||
};
|
||||
|
||||
const MarkdownBlock = ({ children }: { children: string }) => (
|
||||
<div className={markdownStyles.markdown}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
||||
|
||||
const MarkdownBlock = ({ children }: { children: string }) => {
|
||||
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const selectedText = window.getSelection()?.toString();
|
||||
if (!selectedText) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatMetadataValue = (value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
@@ -667,7 +673,6 @@ const PermissionRequestGroup = ({
|
||||
export const AgentTurn = React.memo(
|
||||
({
|
||||
message,
|
||||
branchState,
|
||||
messageSpeechState,
|
||||
onSpeak,
|
||||
onPause,
|
||||
@@ -675,17 +680,13 @@ export const AgentTurn = React.memo(
|
||||
onStopSpeech,
|
||||
isTtsSupported,
|
||||
onRegenerate,
|
||||
onEditResubmit,
|
||||
onCycleBranch,
|
||||
onCreateBranch,
|
||||
onReplyPermission,
|
||||
}: AgentTurnProps) => {
|
||||
const theme = useTheme();
|
||||
const isUser = message.role === "user";
|
||||
const isErrorMessage = Boolean(message.isError);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [editDraft, setEditDraft] = React.useState(message.content);
|
||||
const rootMessageId = message.branchRootId ?? message.id;
|
||||
const isProgressComplete = message.progress?.some(
|
||||
(item) => item.phase === "complete" && item.status === "completed",
|
||||
) ?? false;
|
||||
@@ -720,185 +721,33 @@ export const AgentTurn = React.memo(
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Paper
|
||||
elevation={12}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 5,
|
||||
bgcolor: alpha("#ffffff", 0.75),
|
||||
backdropFilter: "blur(40px)",
|
||||
border: `1px solid ${alpha("#ffffff", 0.9)}`,
|
||||
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||
minWidth: { xs: 260, sm: 320, md: 400 },
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<Box component="textarea"
|
||||
autoFocus
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (editDraft.trim() !== message.content) {
|
||||
onEditResubmit(message.id, editDraft);
|
||||
}
|
||||
setIsEditing(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditDraft(message.content);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
width: "100%",
|
||||
minHeight: 60,
|
||||
bgcolor: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "1rem",
|
||||
color: "text.primary",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="取消"
|
||||
onClick={() => { setEditDraft(message.content); setIsEditing(false); }}
|
||||
sx={{
|
||||
bgcolor: alpha("#000", 0.05),
|
||||
color: "text.secondary",
|
||||
width: 34, height: 34,
|
||||
"&:hover": { bgcolor: alpha("#000", 0.1) }
|
||||
}}
|
||||
>
|
||||
<CloseRounded fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="发送修改"
|
||||
disabled={editDraft.trim() === "" || editDraft.trim() === message.content}
|
||||
onClick={() => {
|
||||
onEditResubmit(message.id, editDraft);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1),
|
||||
color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled",
|
||||
width: 34, height: 34,
|
||||
boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none",
|
||||
"&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) }
|
||||
}}
|
||||
>
|
||||
<SendRounded fontSize="small" sx={{ ml: 0.2 }} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 5,
|
||||
borderBottomRightRadius: 2,
|
||||
color: "#fff",
|
||||
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
|
||||
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
|
||||
backdropFilter: "blur(10px)",
|
||||
"--chat-md-text": alpha("#fff", 0.96),
|
||||
"--chat-md-heading": "#fff",
|
||||
"--chat-md-link": "#e0f7fa",
|
||||
"--chat-md-link-hover": "#fff",
|
||||
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
|
||||
"--chat-md-inline-code-border": alpha("#fff", 0.1),
|
||||
"--chat-md-inline-code-text": "#fff",
|
||||
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
|
||||
"--chat-md-pre-border": alpha("#fff", 0.1),
|
||||
"--chat-md-pre-text": "#F8FAFC",
|
||||
"--chat-md-quote-border": alpha("#fff", 0.4),
|
||||
"--chat-md-quote-bg": alpha("#fff", 0.05),
|
||||
"--chat-md-quote-text": alpha("#fff", 0.8),
|
||||
}}
|
||||
>
|
||||
<MarkdownBlock>{message.content}</MarkdownBlock>
|
||||
|
||||
<AnimatePresence>
|
||||
{isHovered && !isEditing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => { setIsEditing(true); setEditDraft(message.content); }}
|
||||
aria-label="编辑提问"
|
||||
sx={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
bgcolor: alpha("#fff", 0.9),
|
||||
color: "#00acc1",
|
||||
boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`,
|
||||
"&:hover": { bgcolor: "#fff", color: "#00838f" }
|
||||
}}
|
||||
>
|
||||
<EditRounded sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Paper>
|
||||
|
||||
{branchState && branchState.total > 1 ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
sx={{ mt: 0.5, mr: 0.5 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: 4,
|
||||
bgcolor: alpha("#000", 0.04),
|
||||
backdropFilter: "blur(4px)",
|
||||
border: `1px solid ${alpha("#000", 0.08)}`,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="上一分支"
|
||||
onClick={() => onCycleBranch(rootMessageId, -1)}
|
||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||
>
|
||||
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
||||
{branchState.activeIndex + 1} / {branchState.total}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="下一分支"
|
||||
onClick={() => onCycleBranch(rootMessageId, 1)}
|
||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||
>
|
||||
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
</Stack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 5,
|
||||
borderBottomRightRadius: 2,
|
||||
color: "#fff",
|
||||
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
|
||||
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
|
||||
backdropFilter: "blur(10px)",
|
||||
"--chat-md-text": alpha("#fff", 0.96),
|
||||
"--chat-md-heading": "#fff",
|
||||
"--chat-md-link": "#e0f7fa",
|
||||
"--chat-md-link-hover": "#fff",
|
||||
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
|
||||
"--chat-md-inline-code-border": alpha("#fff", 0.1),
|
||||
"--chat-md-inline-code-text": "#fff",
|
||||
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
|
||||
"--chat-md-pre-border": alpha("#fff", 0.1),
|
||||
"--chat-md-pre-text": "#F8FAFC",
|
||||
"--chat-md-quote-border": alpha("#fff", 0.4),
|
||||
"--chat-md-quote-bg": alpha("#fff", 0.05),
|
||||
"--chat-md-quote-text": alpha("#fff", 0.8),
|
||||
}}
|
||||
>
|
||||
<MarkdownBlock>{message.content}</MarkdownBlock>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1060,7 +909,9 @@ export const AgentTurn = React.memo(
|
||||
size="small"
|
||||
aria-label="复制"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
navigator.clipboard.writeText(
|
||||
normalizeClipboardText(message.content),
|
||||
);
|
||||
// Could add a toast here
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
@@ -1073,13 +924,25 @@ export const AgentTurn = React.memo(
|
||||
size="small"
|
||||
aria-label="重新生成"
|
||||
onClick={() => {
|
||||
onRegenerate();
|
||||
onRegenerate(message.id);
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<RefreshRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="拆分为新会话">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="拆分为新会话"
|
||||
onClick={() => {
|
||||
onCreateBranch(message.id);
|
||||
}}
|
||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||
>
|
||||
<TbArrowsSplit2 size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -1088,87 +951,40 @@ export const AgentTurn = React.memo(
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
|
||||
{!isErrorMessage && isTtsSupported ? (
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
|
||||
{!isErrorMessage && isTtsSupported ? (
|
||||
{messageSpeechState === "idle" ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
||||
aria-label="朗读消息"
|
||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||
>
|
||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{messageSpeechState === "playing" ? (
|
||||
<>
|
||||
{messageSpeechState === "idle" ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
|
||||
aria-label="朗读消息"
|
||||
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
|
||||
>
|
||||
<VolumeUpRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{messageSpeechState === "playing" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PauseRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
{messageSpeechState === "paused" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PauseRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
{messageSpeechState === "paused" ? (
|
||||
<>
|
||||
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
|
||||
<PlayArrowRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
|
||||
<StopRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{branchState && branchState.total > 1 ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-start"
|
||||
sx={{ mr: 0.5 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: 4,
|
||||
bgcolor: alpha("#000", 0.04),
|
||||
backdropFilter: "blur(4px)",
|
||||
border: `1px solid ${alpha("#000", 0.08)}`,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="上一分支"
|
||||
onClick={() => onCycleBranch(rootMessageId, -1)}
|
||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||
>
|
||||
<ChevronLeftRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
|
||||
{branchState.activeIndex + 1} / {branchState.total}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="下一分支"
|
||||
onClick={() => onCycleBranch(rootMessageId, 1)}
|
||||
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
|
||||
>
|
||||
<ChevronRightRounded sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user