refactor: simplify chat fork flow
This commit is contained in:
@@ -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}>
|
|
||||||
|
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>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||||
</div>
|
</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,85 +721,6 @@ export const AgentTurn = React.memo(
|
|||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
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
|
<Paper
|
||||||
elevation={4}
|
elevation={4}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -825,80 +747,7 @@ export const AgentTurn = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MarkdownBlock>{message.content}</MarkdownBlock>
|
<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>
|
</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,11 +951,9 @@ 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" ? (
|
{messageSpeechState === "idle" ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1123,52 +984,7 @@ export const AgentTurn = React.memo(
|
|||||||
</IconButton>
|
</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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 +61,15 @@ 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;
|
|
||||||
return (
|
|
||||||
<AgentTurn
|
<AgentTurn
|
||||||
key={rootMessageId}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
branchState={branchStateByRootId.get(rootMessageId)}
|
|
||||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||||
onSpeak={onSpeak}
|
onSpeak={onSpeak}
|
||||||
onPause={onPauseSpeech}
|
onPause={onPauseSpeech}
|
||||||
@@ -103,12 +77,10 @@ const TurnListInner = ({
|
|||||||
onStopSpeech={onStopSpeech}
|
onStopSpeech={onStopSpeech}
|
||||||
isTtsSupported={isTtsSupported}
|
isTtsSupported={isTtsSupported}
|
||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onEditResubmit={onEditResubmit}
|
onCreateBranch={onCreateBranch}
|
||||||
onCycleBranch={onCycleBranch}
|
|
||||||
onReplyPermission={onReplyPermission}
|
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}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -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: [],
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,39 +914,42 @@ 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 targetUser = messages[targetUserIndex];
|
||||||
const nextUserMessage = createUserMessage(
|
const targetUserContent = targetUser.content;
|
||||||
lastUserContent,
|
const nextMessages = cloneMessages(messages.slice(0, targetUserIndex));
|
||||||
lastUser.branchRootId ?? lastUser.id,
|
const nextUserMessage = createUserMessage(targetUserContent);
|
||||||
);
|
|
||||||
const nextAssistantMessage = createAssistantMessage();
|
const nextAssistantMessage = createAssistantMessage();
|
||||||
|
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
await runPrompt({
|
await runPrompt({
|
||||||
prompt: lastUserContent,
|
prompt: targetUserContent,
|
||||||
regenerateFromMessageIndex: lastUserIndex,
|
regenerateFromMessageIndex: targetUserIndex,
|
||||||
preparedMessages: [
|
preparedMessages: [
|
||||||
...nextMessages,
|
...nextMessages,
|
||||||
nextUserMessage,
|
nextUserMessage,
|
||||||
@@ -1028,140 +960,53 @@ export const useAgentChatSession = ({
|
|||||||
});
|
});
|
||||||
}, [isHydrating, isStreaming, messages, runPrompt]);
|
}, [isHydrating, isStreaming, messages, runPrompt]);
|
||||||
|
|
||||||
const editAndResubmit = useCallback(
|
const createBranch = useCallback(
|
||||||
async (messageId: string, newContent: string) => {
|
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",
|
||||||
|
|
||||||
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 keepMessageCount = messageIndex;
|
|
||||||
const prefix = cloneMessages(messages.slice(0, messageIndex));
|
|
||||||
const originalSuffix = cloneMessages(messages.slice(messageIndex));
|
|
||||||
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 (assistantIndex < 0) return;
|
||||||
|
|
||||||
if (groupIndex >= 0) {
|
const currentSessionId = sessionIdRef.current;
|
||||||
const group = next[groupIndex];
|
const keepMessageCount = assistantIndex + 1;
|
||||||
group.branches[group.activeIndex] = {
|
const copiedMessages = cloneMessages(messages.slice(0, keepMessageCount));
|
||||||
...group.branches[group.activeIndex],
|
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
|
||||||
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,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
messages: copiedMessages,
|
||||||
|
sessionId: forkedSessionId,
|
||||||
});
|
});
|
||||||
},
|
setChatSessions(await listChatSessions());
|
||||||
[isHydrating, isStreaming, messages, runPrompt],
|
} catch (error) {
|
||||||
);
|
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
||||||
|
|
||||||
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;
|
|
||||||
setSessionId(selectedBranch.sessionId);
|
|
||||||
setMessages(nextMessages);
|
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user