refactor: simplify chat fork flow
Build Push and Deploy / docker-image (push) Successful in 1m29s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-06-08 16:07:39 +08:00
parent 34fd5bfb1a
commit 2691f42581
10 changed files with 274 additions and 656 deletions
+93 -277
View File
@@ -23,17 +23,14 @@ import {
import type { Theme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import RefreshRounded from "@mui/icons-material/RefreshRounded"; import RefreshRounded from "@mui/icons-material/RefreshRounded";
import EditRounded from "@mui/icons-material/EditRounded"; import { TbArrowsSplit2 } from "react-icons/tb";
import CloseRounded from "@mui/icons-material/CloseRounded";
import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
import { import {
parseAssistantMessageSections, parseAssistantMessageSections,
parseContentWithToolCalls, parseContentWithToolCalls,
type ContentSegment, type ContentSegment,
} from "./chatMessageSections"; } from "./chatMessageSections";
import markdownStyles from "./GlobalChatboxMarkdown.module.css"; 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 { stripMarkdown } from "./GlobalChatbox.utils";
import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart"; import { ChatInlineChart } from "./ChatInlineChart";
@@ -45,7 +42,6 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded"; import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded"; import StopRounded from "@mui/icons-material/StopRounded";
import SendRounded from "@mui/icons-material/SendRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import TerminalRounded from "@mui/icons-material/TerminalRounded"; import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
@@ -58,24 +54,34 @@ import type { PermissionReply } from "@/lib/chatStream";
type AgentTurnProps = { type AgentTurnProps = {
message: Message; message: Message;
branchState?: BranchState;
messageSpeechState: SpeechState; messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (messageId: string, text: string) => void;
onPause: () => void; onPause: () => void;
onResume: () => void; onResume: () => void;
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onRegenerate: () => void; onRegenerate: (messageId: string) => void;
onEditResubmit: (messageId: string, newContent: string) => void; onCreateBranch: (messageId: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void;
}; };
const MarkdownBlock = ({ children }: { children: string }) => ( const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
<div className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown> const MarkdownBlock = ({ children }: { children: string }) => {
</div> 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) => { const formatMetadataValue = (value: unknown) => {
if (typeof value === "string") { if (typeof value === "string") {
@@ -667,7 +673,6 @@ const PermissionRequestGroup = ({
export const AgentTurn = React.memo( export const AgentTurn = React.memo(
({ ({
message, message,
branchState,
messageSpeechState, messageSpeechState,
onSpeak, onSpeak,
onPause, onPause,
@@ -675,17 +680,13 @@ export const AgentTurn = React.memo(
onStopSpeech, onStopSpeech,
isTtsSupported, isTtsSupported,
onRegenerate, onRegenerate,
onEditResubmit, onCreateBranch,
onCycleBranch,
onReplyPermission, onReplyPermission,
}: AgentTurnProps) => { }: AgentTurnProps) => {
const theme = useTheme(); const theme = useTheme();
const isUser = message.role === "user"; const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError); const isErrorMessage = Boolean(message.isError);
const [isHovered, setIsHovered] = React.useState(false); 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( const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed", (item) => item.phase === "complete" && item.status === "completed",
) ?? false; ) ?? false;
@@ -720,185 +721,33 @@ export const AgentTurn = React.memo(
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
{isEditing ? ( <Paper
<Paper elevation={4}
elevation={12} sx={{
sx={{ p: 2,
p: 1.5, borderRadius: 5,
borderRadius: 5, borderBottomRightRadius: 2,
bgcolor: alpha("#ffffff", 0.75), color: "#fff",
backdropFilter: "blur(40px)", background: `linear-gradient(135deg, #0288d1, #00acc1)`,
border: `1px solid ${alpha("#ffffff", 0.9)}`, boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`, backdropFilter: "blur(10px)",
minWidth: { xs: 260, sm: 320, md: 400 }, "--chat-md-text": alpha("#fff", 0.96),
maxWidth: "100%", "--chat-md-heading": "#fff",
}} "--chat-md-link": "#e0f7fa",
> "--chat-md-link-hover": "#fff",
<Box component="textarea" "--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
autoFocus "--chat-md-inline-code-border": alpha("#fff", 0.1),
value={editDraft} "--chat-md-inline-code-text": "#fff",
onChange={(e) => setEditDraft(e.target.value)} "--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
onKeyDown={(e) => { "--chat-md-pre-border": alpha("#fff", 0.1),
if (e.key === "Enter" && !e.shiftKey) { "--chat-md-pre-text": "#F8FAFC",
e.preventDefault(); "--chat-md-quote-border": alpha("#fff", 0.4),
if (editDraft.trim() !== message.content) { "--chat-md-quote-bg": alpha("#fff", 0.05),
onEditResubmit(message.id, editDraft); "--chat-md-quote-text": alpha("#fff", 0.8),
} }}
setIsEditing(false); >
} else if (e.key === "Escape") { <MarkdownBlock>{message.content}</MarkdownBlock>
setEditDraft(message.content); </Paper>
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}
</>
)}
</motion.div> </motion.div>
); );
} }
@@ -1060,7 +909,9 @@ export const AgentTurn = React.memo(
size="small" size="small"
aria-label="复制" aria-label="复制"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(message.content); navigator.clipboard.writeText(
normalizeClipboardText(message.content),
);
// Could add a toast here // Could add a toast here
}} }}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} 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" size="small"
aria-label="重新生成" aria-label="重新生成"
onClick={() => { onClick={() => {
onRegenerate(); onRegenerate(message.id);
}} }}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
> >
<RefreshRounded sx={{ fontSize: 16 }} /> <RefreshRounded sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</Tooltip> </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> </Paper>
</motion.div> </motion.div>
)} )}
@@ -1088,87 +951,40 @@ export const AgentTurn = React.memo(
</Paper> </Paper>
</Stack> </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" 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" }}> <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={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
<IconButton <PauseRounded sx={{ fontSize: 16 }} />
size="small" </IconButton>
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
aria-label="朗读消息" <StopRounded sx={{ fontSize: 16 }} />
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} </IconButton>
> </>
<VolumeUpRounded sx={{ fontSize: 16 }} /> ) : null}
</IconButton> {messageSpeechState === "paused" ? (
) : null} <>
{messageSpeechState === "playing" ? ( <IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
<> <PlayArrowRounded sx={{ fontSize: 16 }} />
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> </IconButton>
<PauseRounded sx={{ fontSize: 16 }} /> <IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
</IconButton> <StopRounded sx={{ fontSize: 16 }} />
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> </IconButton>
<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}
</> </>
) : null} ) : null}
</Stack> </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> </Stack>
) : null} ) : null}
</motion.div> </motion.div>
+1 -4
View File
@@ -33,8 +33,6 @@ jest.mock("./AgentTurn", () => ({
describe("AgentWorkspace", () => { describe("AgentWorkspace", () => {
const defaultProps = { const defaultProps = {
branchGroups: [],
branchTransition: null,
bottomRef: { current: null }, bottomRef: { current: null },
speakingMessageId: null, speakingMessageId: null,
speechState: "idle" as const, speechState: "idle" as const,
@@ -44,8 +42,7 @@ describe("AgentWorkspace", () => {
onStopSpeech: jest.fn(), onStopSpeech: jest.fn(),
isTtsSupported: false, isTtsSupported: false,
onRegenerate: jest.fn(), onRegenerate: jest.fn(),
onEditResubmit: jest.fn(), onCreateBranch: jest.fn(),
onCycleBranch: jest.fn(),
onReplyPermission: jest.fn(), onReplyPermission: jest.fn(),
}; };
+26 -98
View File
@@ -13,17 +13,12 @@ import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts"; import { TypingIndicator } from "./GlobalChatbox.parts";
import type { PermissionReply } from "@/lib/chatStream"; import type { PermissionReply } from "@/lib/chatStream";
import type { import type {
BranchGroup,
BranchState,
BranchTransition,
Message, Message,
SpeechState, SpeechState,
} from "./GlobalChatbox.types"; } from "./GlobalChatbox.types";
type AgentWorkspaceProps = { type AgentWorkspaceProps = {
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[];
branchTransition: BranchTransition | null;
isStreaming: boolean; isStreaming: boolean;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null; speakingMessageId: string | null;
@@ -33,15 +28,13 @@ type AgentWorkspaceProps = {
onResumeSpeech: () => void; onResumeSpeech: () => void;
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onRegenerate: () => void; onRegenerate: (messageId: string) => void;
onEditResubmit: (messageId: string, newContent: string) => void; onCreateBranch: (messageId: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void;
}; };
type TurnListProps = { type TurnListProps = {
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[];
speakingMessageId: string | null; speakingMessageId: string | null;
speechState: SpeechState; speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (messageId: string, text: string) => void;
@@ -49,9 +42,8 @@ type TurnListProps = {
onResumeSpeech: () => void; onResumeSpeech: () => void;
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onRegenerate: () => void; onRegenerate: (messageId: string) => void;
onEditResubmit: (messageId: string, newContent: string) => void; onCreateBranch: (messageId: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void;
}; };
@@ -61,7 +53,6 @@ const sameMessages = (left: Message[], right: Message[]) =>
const TurnListInner = ({ const TurnListInner = ({
messages, messages,
branchGroups,
speakingMessageId, speakingMessageId,
speechState, speechState,
onSpeak, onSpeak,
@@ -70,45 +61,26 @@ const TurnListInner = ({
onStopSpeech, onStopSpeech,
isTtsSupported, isTtsSupported,
onRegenerate, onRegenerate,
onEditResubmit, onCreateBranch,
onCycleBranch,
onReplyPermission, onReplyPermission,
}: TurnListProps) => { }: 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 ( return (
<> <>
{messages.map((message) => { {messages.map((message) => (
const rootMessageId = message.branchRootId ?? message.id; <AgentTurn
return ( key={message.id}
<AgentTurn message={message}
key={rootMessageId} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
message={message} onSpeak={onSpeak}
branchState={branchStateByRootId.get(rootMessageId)} onPause={onPauseSpeech}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} onResume={onResumeSpeech}
onSpeak={onSpeak} onStopSpeech={onStopSpeech}
onPause={onPauseSpeech} isTtsSupported={isTtsSupported}
onResume={onResumeSpeech} onRegenerate={onRegenerate}
onStopSpeech={onStopSpeech} onCreateBranch={onCreateBranch}
isTtsSupported={isTtsSupported} onReplyPermission={onReplyPermission}
onRegenerate={onRegenerate} />
onEditResubmit={onEditResubmit} ))}
onCycleBranch={onCycleBranch}
onReplyPermission={onReplyPermission}
/>
);
})}
</> </>
); );
}; };
@@ -117,7 +89,6 @@ const TurnList = React.memo(
TurnListInner, TurnListInner,
(prevProps, nextProps) => (prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) && sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.branchGroups === nextProps.branchGroups &&
prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId &&
prevProps.speechState === nextProps.speechState && prevProps.speechState === nextProps.speechState &&
prevProps.onSpeak === nextProps.onSpeak && prevProps.onSpeak === nextProps.onSpeak &&
@@ -126,8 +97,7 @@ const TurnList = React.memo(
prevProps.onStopSpeech === nextProps.onStopSpeech && prevProps.onStopSpeech === nextProps.onStopSpeech &&
prevProps.isTtsSupported === nextProps.isTtsSupported && prevProps.isTtsSupported === nextProps.isTtsSupported &&
prevProps.onRegenerate === nextProps.onRegenerate && prevProps.onRegenerate === nextProps.onRegenerate &&
prevProps.onEditResubmit === nextProps.onEditResubmit && prevProps.onCreateBranch === nextProps.onCreateBranch &&
prevProps.onCycleBranch === nextProps.onCycleBranch &&
prevProps.onReplyPermission === nextProps.onReplyPermission, prevProps.onReplyPermission === nextProps.onReplyPermission,
); );
@@ -249,8 +219,6 @@ const EmptyState = () => {
export const AgentWorkspace = ({ export const AgentWorkspace = ({
messages, messages,
branchGroups,
branchTransition,
isStreaming, isStreaming,
bottomRef, bottomRef,
speakingMessageId, speakingMessageId,
@@ -261,8 +229,7 @@ export const AgentWorkspace = ({
onStopSpeech, onStopSpeech,
isTtsSupported, isTtsSupported,
onRegenerate, onRegenerate,
onEditResubmit, onCreateBranch,
onCycleBranch,
onReplyPermission, onReplyPermission,
}: AgentWorkspaceProps) => { }: AgentWorkspaceProps) => {
const theme = useTheme(); const theme = useTheme();
@@ -274,18 +241,12 @@ export const AgentWorkspace = ({
(!latestAssistant || (!latestAssistant ||
(latestAssistant.content.trim().length === 0 && (latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length))); !(latestAssistant.artifacts?.length)));
const stableMessages = branchTransition
? messages.slice(0, branchTransition.parentCount)
: messages;
const transitionMessages = branchTransition
? messages.slice(branchTransition.parentCount)
: [];
const streamingMessage = const streamingMessage =
!branchTransition && isStreaming && messages.at(-1)?.role === "assistant" isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1) ? messages.at(-1)
: undefined; : undefined;
const historyMessages = const historyMessages =
streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages; streamingMessage !== undefined ? messages.slice(0, -1) : messages;
return ( return (
<Box <Box
@@ -307,7 +268,6 @@ export const AgentWorkspace = ({
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TurnList <TurnList
messages={historyMessages} messages={historyMessages}
branchGroups={branchGroups}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
@@ -316,15 +276,13 @@ export const AgentWorkspace = ({
onStopSpeech={onStopSpeech} onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit} onCreateBranch={onCreateBranch}
onCycleBranch={onCycleBranch}
onReplyPermission={onReplyPermission} onReplyPermission={onReplyPermission}
/> />
{streamingMessage ? ( {streamingMessage ? (
<TurnList <TurnList
messages={[streamingMessage]} messages={[streamingMessage]}
branchGroups={branchGroups}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
@@ -333,40 +291,10 @@ export const AgentWorkspace = ({
onStopSpeech={onStopSpeech} onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate} onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit} onCreateBranch={onCreateBranch}
onCycleBranch={onCycleBranch}
onReplyPermission={onReplyPermission} onReplyPermission={onReplyPermission}
/> />
) : null} ) : null}
{branchTransition ? (
<AnimatePresence initial={false} mode="wait">
<motion.div
key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
<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}
onReplyPermission={onReplyPermission}
/>
</motion.div>
</AnimatePresence>
) : null}
</Box> </Box>
) : null} ) : null}
+2 -8
View File
@@ -67,15 +67,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
messages, messages,
chatSessions, chatSessions,
activeSessionId, activeSessionId,
branchGroups,
branchTransition,
isHydrating, isHydrating,
isStreaming, isStreaming,
sessionTitle, sessionTitle,
sendPrompt, sendPrompt,
regenerate, regenerate,
editAndResubmit, createBranch,
cycleBranch,
abort, abort,
replyPermission, replyPermission,
createSession, createSession,
@@ -344,8 +341,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}> <Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
<AgentWorkspace <AgentWorkspace
messages={messages} messages={messages}
branchGroups={branchGroups}
branchTransition={branchTransition}
isStreaming={isStreaming} isStreaming={isStreaming}
bottomRef={bottomRef} bottomRef={bottomRef}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
@@ -356,8 +351,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onStopSpeech={handleStopSpeech} onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onRegenerate={regenerate} onRegenerate={regenerate}
onEditResubmit={editAndResubmit} onCreateBranch={createBranch}
onCycleBranch={cycleBranch}
onReplyPermission={replyPermission} onReplyPermission={replyPermission}
/> />
@@ -55,34 +55,6 @@ export type Message = {
progress?: ChatProgress[]; progress?: ChatProgress[];
artifacts?: AgentArtifact[]; artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[]; permissions?: AgentPermissionRequest[];
branchRootId?: string;
};
export type BranchState = {
activeIndex: number;
total: number;
};
export type MessageBranch = {
id: string;
label: string;
sessionId?: string;
messages: Message[];
};
export type BranchGroup = {
id: string;
rootMessageId: string;
parentCount: number;
activeIndex: number;
branches: MessageBranch[];
};
export type BranchTransition = {
rootMessageId: string;
parentCount: number;
activeBranchId: string;
nonce: number;
}; };
export type Props = { export type Props = {
@@ -106,7 +78,6 @@ export type LoadedChatState = {
title?: string; title?: string;
isTitleManuallyEdited?: boolean; isTitleManuallyEdited?: boolean;
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[];
isStreaming?: boolean; isStreaming?: boolean;
runStatus?: string; runStatus?: string;
}; };
+1 -10
View File
@@ -1,4 +1,4 @@
import type { BranchGroup, Message } from "./GlobalChatbox.types"; import type { Message } from "./GlobalChatbox.types";
export const createId = () => export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -36,12 +36,3 @@ export const cloneMessage = (message: Message): Message => ({
}); });
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage); export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
branchGroups.map((group) => ({
...group,
branches: group.branches.map((branch) => ({
...branch,
messages: cloneMessages(branch.messages),
})),
}));
-3
View File
@@ -21,7 +21,6 @@ describe("chatStorage backend-only persistence", () => {
title: undefined, title: undefined,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
expect(apiFetch).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled();
}); });
@@ -60,11 +59,9 @@ describe("chatStorage backend-only persistence", () => {
id: "message-2", id: "message-2",
role: "user", role: "user",
content: "第一条消息", content: "第一条消息",
branchRootId: "message-2",
}, },
], ],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}, },
); );
+1 -11
View File
@@ -2,12 +2,11 @@ import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config"; import { config } from "@config/config";
import type { import type {
BranchGroup,
ChatSessionSummary, ChatSessionSummary,
LoadedChatState, LoadedChatState,
Message, Message,
} from "./GlobalChatbox.types"; } from "./GlobalChatbox.types";
import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; import { cloneMessages } from "./GlobalChatbox.utils";
type BackendSessionPayload = { type BackendSessionPayload = {
id?: string; id?: string;
@@ -23,22 +22,16 @@ export const createEmptyChatState = (): LoadedChatState => ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
const sanitizeMessages = (messages: Message[] | undefined) => const sanitizeMessages = (messages: Message[] | undefined) =>
Array.isArray(messages) ? cloneMessages(messages) : []; Array.isArray(messages) ? cloneMessages(messages) : [];
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
const hasChatContent = (state: { const hasChatContent = (state: {
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[];
sessionId?: string; sessionId?: string;
}) => }) =>
state.messages.length > 0 || state.messages.length > 0 ||
state.branchGroups.length > 0 ||
Boolean(state.sessionId); Boolean(state.sessionId);
const compareSessionsByAnchorTime = ( const compareSessionsByAnchorTime = (
@@ -107,7 +100,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
is_title_manually_edited?: boolean; is_title_manually_edited?: boolean;
session_id?: string; session_id?: string;
messages?: Message[]; messages?: Message[];
branch_groups?: BranchGroup[];
is_streaming?: boolean; is_streaming?: boolean;
run_status?: string; run_status?: string;
}; };
@@ -116,7 +108,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
isTitleManuallyEdited: payload.is_title_manually_edited ?? false, isTitleManuallyEdited: payload.is_title_manually_edited ?? false,
messages: sanitizeMessages(payload.messages), messages: sanitizeMessages(payload.messages),
sessionId: payload.session_id ?? payload.id, sessionId: payload.session_id ?? payload.id,
branchGroups: sanitizeBranchGroups(payload.branch_groups),
isStreaming: payload.is_streaming ?? false, isStreaming: payload.is_streaming ?? false,
runStatus: payload.run_status, runStatus: payload.run_status,
}; };
@@ -167,7 +158,6 @@ const saveBackendChatState = async (
title: normalizeTitle(state.title), title: normalizeTitle(state.title),
is_title_manually_edited: state.isTitleManuallyEdited ?? false, is_title_manually_edited: state.isTitleManuallyEdited ?? false,
messages: sanitizeMessages(state.messages), messages: sanitizeMessages(state.messages),
branch_groups: sanitizeBranchGroups(state.branchGroups),
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
userHeaderMode: "include", userHeaderMode: "include",
@@ -5,6 +5,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession"; import { useAgentChatSession } from "./useAgentChatSession";
import { import {
abortAgentChat, abortAgentChat,
forkAgentChat,
replyAgentPermission, replyAgentPermission,
resumeAgentChatStream, resumeAgentChatStream,
streamAgentChat, streamAgentChat,
@@ -30,7 +31,6 @@ jest.mock("../chatStorage", () => ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
})), })),
deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), deleteChatSession: (...args: unknown[]) => deleteChatSession(...args),
listChatSessions: (...args: unknown[]) => listChatSessions(...args), listChatSessions: (...args: unknown[]) => listChatSessions(...args),
@@ -39,7 +39,6 @@ jest.mock("../chatStorage", () => ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: "session-loaded", sessionId: "session-loaded",
branchGroups: [],
})), })),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
@@ -52,10 +51,12 @@ describe("useAgentChatSession", () => {
saveActiveChatState.mockReset(); saveActiveChatState.mockReset();
updateChatSessionTitle.mockReset(); updateChatSessionTitle.mockReset();
jest.mocked(abortAgentChat).mockReset(); jest.mocked(abortAgentChat).mockReset();
jest.mocked(forkAgentChat).mockReset();
jest.mocked(replyAgentPermission).mockReset(); jest.mocked(replyAgentPermission).mockReset();
jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(resumeAgentChatStream).mockReset();
jest.mocked(streamAgentChat).mockReset(); jest.mocked(streamAgentChat).mockReset();
jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
jest.mocked(streamAgentChat).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
@@ -596,9 +597,10 @@ describe("useAgentChatSession", () => {
await act(async () => { await act(async () => {
await result.current.sendPrompt("重新分析压力异常"); await result.current.sendPrompt("重新分析压力异常");
}); });
const assistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => { await act(async () => {
await result.current.regenerate(); await result.current.regenerate(assistantMessageId);
}); });
expect(streamAgentChat).toHaveBeenNthCalledWith( expect(streamAgentChat).toHaveBeenNthCalledWith(
@@ -609,4 +611,91 @@ describe("useAgentChatSession", () => {
}), }),
); );
}); });
it("replaces the current chain when regenerating a middle assistant message", async () => {
listChatSessions.mockResolvedValue([]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.sendPrompt("第一轮");
});
await act(async () => {
await result.current.sendPrompt("第二轮");
});
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => {
await result.current.regenerate(firstAssistantMessageId);
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: "user",
content: "第一轮",
}),
);
expect(result.current.messages[1]).toEqual(
expect.objectContaining({
role: "assistant",
content: "",
}),
);
expect(streamAgentChat).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
message: "第一轮",
regenerateFromMessageIndex: 0,
}),
);
});
it("forks a copied conversation from an assistant message", async () => {
listChatSessions.mockResolvedValue([]);
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.sendPrompt("第一轮");
});
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => {
await result.current.createBranch(firstAssistantMessageId);
});
expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2);
expect(result.current.activeSessionId).toBe("forked-session");
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toEqual(
expect.objectContaining({
role: "user",
content: "第一轮",
}),
);
expect(result.current.messages[1]).toEqual(
expect.objectContaining({
role: "assistant",
}),
);
expect(streamAgentChat).toHaveBeenCalledTimes(1);
});
}); });
+58 -213
View File
@@ -18,15 +18,12 @@ import type {
import type { import type {
AgentArtifact, AgentArtifact,
AgentPermissionRequest, AgentPermissionRequest,
BranchGroup,
BranchTransition,
ChatProgress, ChatProgress,
ChatSessionSummary, ChatSessionSummary,
LoadedChatState, LoadedChatState,
Message, Message,
} from "../GlobalChatbox.types"; } from "../GlobalChatbox.types";
import { import {
cloneBranchGroups,
cloneMessages, cloneMessages,
createId, createId,
} from "../GlobalChatbox.utils"; } from "../GlobalChatbox.utils";
@@ -68,7 +65,6 @@ const createPersistedStateKey = (state: LoadedChatState) =>
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null, sessionId: state.sessionId ?? null,
messages: state.messages, messages: state.messages,
branchGroups: state.branchGroups,
}); });
const upsertProgress = ( const upsertProgress = (
@@ -193,13 +189,12 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
}; };
}; };
const createUserMessage = (content: string, branchRootId?: string): Message => { const createUserMessage = (content: string): Message => {
const id = createId(); const id = createId();
return { return {
id, id,
role: "user", role: "user",
content, content,
branchRootId: branchRootId ?? id,
}; };
}; };
@@ -209,9 +204,6 @@ const createAssistantMessage = (): Message => ({
content: "", content: "",
}); });
const messagesEqual = (left: Message[], right: Message[]) =>
JSON.stringify(left) === JSON.stringify(right);
export const useAgentChatSession = ({ export const useAgentChatSession = ({
projectId, projectId,
onToolCall, onToolCall,
@@ -226,15 +218,12 @@ export const useAgentChatSession = ({
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined); const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false); const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined); const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]); const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true); const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(undefined); const sessionIdRef = useRef<string | undefined>(undefined);
const messagesRef = useRef<Message[]>([]); const messagesRef = useRef<Message[]>([]);
const branchGroupsRef = useRef<BranchGroup[]>([]);
const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null); const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null);
const isSessionTitleManuallyEditedRef = useRef(false); const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null); const cancelPromiseRef = useRef<Promise<void> | null>(null);
@@ -245,7 +234,6 @@ export const useAgentChatSession = ({
title: undefined, title: undefined,
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
branchGroups: [],
}), }),
); );
@@ -257,9 +245,6 @@ export const useAgentChatSession = ({
messagesRef.current = messages; messagesRef.current = messages;
}, [messages]); }, [messages]);
useEffect(() => {
branchGroupsRef.current = branchGroups;
}, [branchGroups]);
useEffect(() => { useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
@@ -279,17 +264,14 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
hydrationCompletedRef.current = true; hydrationCompletedRef.current = true;
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
setBranchTransition(null);
setMessages([]); setMessages([]);
setSessionTitle(undefined); setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setBranchGroups([]);
setChatSessions([]); setChatSessions([]);
setIsHydrating(false); setIsHydrating(false);
return; return;
@@ -313,7 +295,6 @@ export const useAgentChatSession = ({
setSessionTitle(loadedState.title); setSessionTitle(loadedState.title);
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId); setSessionId(loadedState.sessionId);
setBranchGroups(loadedState.branchGroups);
setChatSessions(sessions); setChatSessions(sessions);
if ( if (
loadedState.sessionId && loadedState.sessionId &&
@@ -351,7 +332,6 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: isSessionTitleManuallyEdited, isTitleManuallyEdited: isSessionTitleManuallyEdited,
messages, messages,
sessionId, sessionId,
branchGroups,
}; };
const currentStateKey = createPersistedStateKey(state); const currentStateKey = createPersistedStateKey(state);
@@ -381,46 +361,7 @@ export const useAgentChatSession = ({
return () => { return () => {
window.clearTimeout(persistTimer); window.clearTimeout(persistTimer);
}; };
}, [branchGroups, isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); }, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
useEffect(() => {
setBranchGroups((prev) => {
let changed = false;
const next = prev.map((group) => {
const rootMessage = messages[group.parentCount];
if (
!rootMessage ||
rootMessage.role !== "user" ||
(rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId
) {
return group;
}
const activeBranch = group.branches[group.activeIndex];
if (!activeBranch) {
return group;
}
const nextSuffix = cloneMessages(messages.slice(group.parentCount));
if (
activeBranch.sessionId === sessionId &&
messagesEqual(activeBranch.messages, nextSuffix)
) {
return group;
}
changed = true;
const branches = group.branches.map((branch, index) =>
index === group.activeIndex
? { ...branch, sessionId, messages: nextSuffix }
: branch,
);
return { ...group, branches };
});
return changed ? next : prev;
});
}, [messages, sessionId]);
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
setMessages((prev) => setMessages((prev) =>
@@ -479,7 +420,6 @@ export const useAgentChatSession = ({
title: nextTitle, title: nextTitle,
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: messagesRef.current, messages: messagesRef.current,
branchGroups: branchGroupsRef.current,
}); });
} }
if (targetSessionId) { if (targetSessionId) {
@@ -643,7 +583,6 @@ export const useAgentChatSession = ({
await cancelPromiseRef.current?.catch(() => undefined); await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.(); onBeforeSend?.();
setBranchTransition(null);
const nextUserMessage = userMessage ?? createUserMessage(prompt); const nextUserMessage = userMessage ?? createUserMessage(prompt);
const nextAssistantMessage = assistantMessage ?? createAssistantMessage(); const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
@@ -832,7 +771,6 @@ export const useAgentChatSession = ({
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
setBranchTransition(null);
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
sessionIdRef.current = undefined; sessionIdRef.current = undefined;
@@ -841,13 +779,11 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
setMessages([]); setMessages([]);
setSessionTitle("新对话"); setSessionTitle("新对话");
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setBranchGroups([]);
setIsStreaming(false); setIsStreaming(false);
}, [isHydrating, isStreaming]); }, [isHydrating, isStreaming]);
@@ -868,12 +804,10 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId; sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null);
setMessages(nextState.messages); setMessages(nextState.messages);
setSessionTitle(nextState.title); setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId); setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessions); setChatSessions(sessions);
if (nextState.sessionId && nextState.isStreaming) { if (nextState.sessionId && nextState.isStreaming) {
resumeStreamingSession(nextState.sessionId); resumeStreamingSession(nextState.sessionId);
@@ -917,14 +851,11 @@ export const useAgentChatSession = ({
isTitleManuallyEdited: false, isTitleManuallyEdited: false,
messages: [], messages: [],
sessionId: undefined, sessionId: undefined,
branchGroups: [],
}); });
setBranchTransition(null);
setMessages([]); setMessages([]);
setSessionTitle(undefined); setSessionTitle(undefined);
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setBranchGroups([]);
return; return;
} }
@@ -937,12 +868,10 @@ export const useAgentChatSession = ({
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
sessionIdRef.current = nextState.sessionId; sessionIdRef.current = nextState.sessionId;
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
setBranchTransition(null);
setMessages(nextState.messages); setMessages(nextState.messages);
setSessionTitle(nextState.title); setSessionTitle(nextState.title);
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
setSessionId(nextState.sessionId); setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessionsAfterDelete); setChatSessions(sessionsAfterDelete);
} catch (error) { } catch (error) {
console.error("[GlobalChatbox] Failed to delete chat session:", error); console.error("[GlobalChatbox] Failed to delete chat session:", error);
@@ -985,183 +914,99 @@ export const useAgentChatSession = ({
title: normalizedTitle, title: normalizedTitle,
isTitleManuallyEdited: true, isTitleManuallyEdited: true,
messages, messages,
branchGroups,
}); });
} }
} catch (error) { } catch (error) {
console.error("[GlobalChatbox] Failed to rename chat session:", error); console.error("[GlobalChatbox] Failed to rename chat session:", error);
} }
}, },
[branchGroups, isHydrating, messages], [isHydrating, messages],
); );
const regenerate = useCallback(async () => { const regenerate = useCallback(async (messageId: string) => {
if (isHydrating || isStreaming || messages.length === 0) return; if (isHydrating || isStreaming || messages.length === 0) return;
let lastUserIndex = messages.length - 1; const targetAssistantIndex = messages.findIndex(
while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") { (message) => message.id === messageId && message.role === "assistant",
lastUserIndex--; );
if (targetAssistantIndex < 0) {
return;
} }
if (lastUserIndex < 0) return; let targetUserIndex = targetAssistantIndex - 1;
while (targetUserIndex >= 0 && messages[targetUserIndex].role !== "user") {
targetUserIndex--;
}
const lastUser = messages[lastUserIndex]; if (targetUserIndex < 0) return;
const lastUserContent = lastUser.content;
const nextMessages = cloneMessages(messages.slice(0, lastUserIndex));
const nextUserMessage = createUserMessage(
lastUserContent,
lastUser.branchRootId ?? lastUser.id,
);
const nextAssistantMessage = createAssistantMessage();
setMessages(nextMessages); const targetUser = messages[targetUserIndex];
await runPrompt({ const targetUserContent = targetUser.content;
prompt: lastUserContent, const nextMessages = cloneMessages(messages.slice(0, targetUserIndex));
regenerateFromMessageIndex: lastUserIndex, const nextUserMessage = createUserMessage(targetUserContent);
preparedMessages: [ const nextAssistantMessage = createAssistantMessage();
...nextMessages,
nextUserMessage,
nextAssistantMessage,
],
userMessage: nextUserMessage,
assistantMessage: nextAssistantMessage,
});
}, [isHydrating, isStreaming, messages, runPrompt]);
const editAndResubmit = useCallback( setMessages(nextMessages);
async (messageId: string, newContent: string) => { await runPrompt({
prompt: targetUserContent,
regenerateFromMessageIndex: targetUserIndex,
preparedMessages: [
...nextMessages,
nextUserMessage,
nextAssistantMessage,
],
userMessage: nextUserMessage,
assistantMessage: nextAssistantMessage,
});
}, [isHydrating, isStreaming, messages, runPrompt]);
const createBranch = useCallback(
async (messageId: string) => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
const trimmedContent = newContent.trim(); const assistantIndex = messages.findIndex(
if (!trimmedContent) return; (message) => message.id === messageId && message.role === "assistant",
);
if (assistantIndex < 0) return;
const messageIndex = messages.findIndex((m) => m.id === messageId);
if (messageIndex < 0 || messages[messageIndex].role !== "user") return;
const originalMessage = messages[messageIndex];
if (trimmedContent === originalMessage.content.trim()) return;
const rootMessageId = originalMessage.branchRootId ?? originalMessage.id;
const currentSessionId = sessionIdRef.current; const currentSessionId = sessionIdRef.current;
const keepMessageCount = messageIndex; const keepMessageCount = assistantIndex + 1;
const prefix = cloneMessages(messages.slice(0, messageIndex)); const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount));
const originalSuffix = cloneMessages(messages.slice(messageIndex));
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount); const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
const nextUserMessage = createUserMessage(trimmedContent, rootMessageId);
const nextAssistantMessage = createAssistantMessage();
const nextSuffix = [nextUserMessage, nextAssistantMessage];
setBranchGroups((prev) => {
const next = cloneBranchGroups(prev);
const groupIndex = next.findIndex(
(group) =>
group.rootMessageId === rootMessageId && group.parentCount === messageIndex,
);
if (groupIndex >= 0) {
const group = next[groupIndex];
group.branches[group.activeIndex] = {
...group.branches[group.activeIndex],
sessionId: currentSessionId,
messages: originalSuffix,
};
group.branches.push({
id: createId(),
label: `分支 ${group.branches.length + 1}`,
sessionId: forkedSessionId,
messages: cloneMessages(nextSuffix),
});
group.activeIndex = group.branches.length - 1;
} else {
next.push({
id: rootMessageId,
rootMessageId,
parentCount: messageIndex,
activeIndex: 1,
branches: [
{
id: createId(),
label: "分支 1",
sessionId: currentSessionId,
messages: originalSuffix,
},
{
id: createId(),
label: "分支 2",
sessionId: forkedSessionId,
messages: cloneMessages(nextSuffix),
},
],
});
}
return next;
});
sessionIdRef.current = forkedSessionId; sessionIdRef.current = forkedSessionId;
setSessionId(forkedSessionId); setSessionId(forkedSessionId);
await runPrompt({ messagesRef.current = copiedMessages;
prompt: trimmedContent, setMessages(copiedMessages);
sessionIdOverride: forkedSessionId, setIsSessionTitleManuallyEdited(false);
preparedMessages: [...prefix, ...nextSuffix], const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
userMessage: nextUserMessage, setSessionTitle(forkTitle);
assistantMessage: nextAssistantMessage, try {
}); await saveActiveChatState({
}, title: forkTitle,
[isHydrating, isStreaming, messages, runPrompt], isTitleManuallyEdited: false,
); messages: copiedMessages,
sessionId: forkedSessionId,
const cycleBranch = useCallback(
(rootMessageId: string, direction: -1 | 1) => {
if (isHydrating || isStreaming) return;
setBranchGroups((prev) => {
const next = cloneBranchGroups(prev);
const group = next.find((item) => item.rootMessageId === rootMessageId);
if (!group || group.branches.length < 2) {
return prev;
}
const nextIndex =
(group.activeIndex + direction + group.branches.length) % group.branches.length;
const selectedBranch = group.branches[nextIndex];
group.activeIndex = nextIndex;
const nextMessages = [
...cloneMessages(messages.slice(0, group.parentCount)),
...cloneMessages(selectedBranch.messages),
];
setBranchTransition({
rootMessageId,
parentCount: group.parentCount,
activeBranchId: selectedBranch.id,
nonce: Date.now(),
}); });
sessionIdRef.current = selectedBranch.sessionId; setChatSessions(await listChatSessions());
setSessionId(selectedBranch.sessionId); } catch (error) {
setMessages(nextMessages); console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
}
return next;
});
}, },
[isHydrating, isStreaming, messages], [isHydrating, isStreaming, messages, sessionTitle],
); );
return { return {
messages, messages,
chatSessions, chatSessions,
activeSessionId: sessionIdRef.current, activeSessionId: sessionIdRef.current,
branchGroups,
branchTransition,
isHydrating, isHydrating,
isStreaming, isStreaming,
sessionTitle, sessionTitle,
sessionId, sessionId,
sendPrompt, sendPrompt,
regenerate, regenerate,
editAndResubmit, createBranch,
cycleBranch,
abort, abort,
replyPermission, replyPermission,
createSession, createSession,