diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index 9b53bc1..9043a8d 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -23,17 +23,14 @@ import { import type { Theme } from "@mui/material/styles"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import RefreshRounded from "@mui/icons-material/RefreshRounded"; -import EditRounded from "@mui/icons-material/EditRounded"; -import CloseRounded from "@mui/icons-material/CloseRounded"; -import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded"; -import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded"; +import { TbArrowsSplit2 } from "react-icons/tb"; import { parseAssistantMessageSections, parseContentWithToolCalls, type ContentSegment, } from "./chatMessageSections"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; -import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types"; +import type { Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { ChatInlineChart } from "./ChatInlineChart"; @@ -45,7 +42,6 @@ import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; import PauseRounded from "@mui/icons-material/PauseRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import StopRounded from "@mui/icons-material/StopRounded"; -import SendRounded from "@mui/icons-material/SendRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import TerminalRounded from "@mui/icons-material/TerminalRounded"; import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; @@ -58,24 +54,34 @@ import type { PermissionReply } from "@/lib/chatStream"; type AgentTurnProps = { message: Message; - branchState?: BranchState; messageSpeechState: SpeechState; onSpeak: (messageId: string, text: string) => void; onPause: () => void; onResume: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: () => void; - onEditResubmit: (messageId: string, newContent: string) => void; - onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onRegenerate: (messageId: string) => void; + onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; -const MarkdownBlock = ({ children }: { children: string }) => ( -
- {children} -
-); +const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, ""); + +const MarkdownBlock = ({ children }: { children: string }) => { + const handleCopy = React.useCallback((event: React.ClipboardEvent) => { + const selectedText = window.getSelection()?.toString(); + if (!selectedText) return; + + event.preventDefault(); + event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText)); + }, []); + + return ( +
+ {children} +
+ ); +}; const formatMetadataValue = (value: unknown) => { if (typeof value === "string") { @@ -667,7 +673,6 @@ const PermissionRequestGroup = ({ export const AgentTurn = React.memo( ({ message, - branchState, messageSpeechState, onSpeak, onPause, @@ -675,17 +680,13 @@ export const AgentTurn = React.memo( onStopSpeech, isTtsSupported, onRegenerate, - onEditResubmit, - onCycleBranch, + onCreateBranch, onReplyPermission, }: AgentTurnProps) => { const theme = useTheme(); const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); const [isHovered, setIsHovered] = React.useState(false); - const [isEditing, setIsEditing] = React.useState(false); - const [editDraft, setEditDraft] = React.useState(message.content); - const rootMessageId = message.branchRootId ?? message.id; const isProgressComplete = message.progress?.some( (item) => item.phase === "complete" && item.status === "completed", ) ?? false; @@ -720,185 +721,33 @@ export const AgentTurn = React.memo( onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - {isEditing ? ( - - 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, - }} - /> - - { setEditDraft(message.content); setIsEditing(false); }} - sx={{ - bgcolor: alpha("#000", 0.05), - color: "text.secondary", - width: 34, height: 34, - "&:hover": { bgcolor: alpha("#000", 0.1) } - }} - > - - - { - 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) } - }} - > - - - - - ) : ( - <> - - {message.content} - - - {isHovered && !isEditing && ( - - { 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" } - }} - > - - - - )} - - - - {branchState && branchState.total > 1 ? ( - - - onCycleBranch(rootMessageId, -1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - - - - {branchState.activeIndex + 1} / {branchState.total} - - onCycleBranch(rootMessageId, 1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - - - - - ) : null} - - )} + + {message.content} + ); } @@ -1060,7 +909,9 @@ export const AgentTurn = React.memo( size="small" aria-label="复制" onClick={() => { - navigator.clipboard.writeText(message.content); + navigator.clipboard.writeText( + normalizeClipboardText(message.content), + ); // Could add a toast here }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} @@ -1073,13 +924,25 @@ export const AgentTurn = React.memo( size="small" aria-label="重新生成" onClick={() => { - onRegenerate(); + onRegenerate(message.id); }} sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} > + + { + onCreateBranch(message.id); + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + + + )} @@ -1088,87 +951,40 @@ export const AgentTurn = React.memo( - {(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? ( + {!isErrorMessage && isTtsSupported ? ( - {!isErrorMessage && isTtsSupported ? ( + {messageSpeechState === "idle" ? ( + onSpeak(message.id, stripMarkdown(answerContent))} + aria-label="朗读消息" + sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} + > + + + ) : null} + {messageSpeechState === "playing" ? ( <> - {messageSpeechState === "idle" ? ( - onSpeak(message.id, stripMarkdown(answerContent))} - aria-label="朗读消息" - sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} - > - - - ) : null} - {messageSpeechState === "playing" ? ( - <> - - - - - - - - ) : null} - {messageSpeechState === "paused" ? ( - <> - - - - - - - - ) : null} + + + + + + + + ) : null} + {messageSpeechState === "paused" ? ( + <> + + + + + + ) : null} - - {branchState && branchState.total > 1 ? ( - - - onCycleBranch(rootMessageId, -1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - - - - {branchState.activeIndex + 1} / {branchState.total} - - onCycleBranch(rootMessageId, 1)} - sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} - > - - - - - ) : null} ) : null} diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index 9efa895..20bc225 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -33,8 +33,6 @@ jest.mock("./AgentTurn", () => ({ describe("AgentWorkspace", () => { const defaultProps = { - branchGroups: [], - branchTransition: null, bottomRef: { current: null }, speakingMessageId: null, speechState: "idle" as const, @@ -44,8 +42,7 @@ describe("AgentWorkspace", () => { onStopSpeech: jest.fn(), isTtsSupported: false, onRegenerate: jest.fn(), - onEditResubmit: jest.fn(), - onCycleBranch: jest.fn(), + onCreateBranch: jest.fn(), onReplyPermission: jest.fn(), }; diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index ba15fa8..37e415f 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -13,17 +13,12 @@ import { AgentTurn } from "./AgentTurn"; import { TypingIndicator } from "./GlobalChatbox.parts"; import type { PermissionReply } from "@/lib/chatStream"; import type { - BranchGroup, - BranchState, - BranchTransition, Message, SpeechState, } from "./GlobalChatbox.types"; type AgentWorkspaceProps = { messages: Message[]; - branchGroups: BranchGroup[]; - branchTransition: BranchTransition | null; isStreaming: boolean; bottomRef: React.RefObject; speakingMessageId: string | null; @@ -33,15 +28,13 @@ type AgentWorkspaceProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: () => void; - onEditResubmit: (messageId: string, newContent: string) => void; - onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onRegenerate: (messageId: string) => void; + onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; type TurnListProps = { messages: Message[]; - branchGroups: BranchGroup[]; speakingMessageId: string | null; speechState: SpeechState; onSpeak: (messageId: string, text: string) => void; @@ -49,9 +42,8 @@ type TurnListProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; - onRegenerate: () => void; - onEditResubmit: (messageId: string, newContent: string) => void; - onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; + onRegenerate: (messageId: string) => void; + onCreateBranch: (messageId: string) => void; onReplyPermission: (requestId: string, reply: PermissionReply) => void; }; @@ -61,7 +53,6 @@ const sameMessages = (left: Message[], right: Message[]) => const TurnListInner = ({ messages, - branchGroups, speakingMessageId, speechState, onSpeak, @@ -70,45 +61,26 @@ const TurnListInner = ({ onStopSpeech, isTtsSupported, onRegenerate, - onEditResubmit, - onCycleBranch, + onCreateBranch, onReplyPermission, }: TurnListProps) => { - const branchStateByRootId = React.useMemo(() => { - const next = new Map(); - branchGroups.forEach((group) => { - if (group.branches.length > 1) { - next.set(group.rootMessageId, { - activeIndex: group.activeIndex, - total: group.branches.length, - }); - } - }); - return next; - }, [branchGroups]); - return ( <> - {messages.map((message) => { - const rootMessageId = message.branchRootId ?? message.id; - return ( - - ); - })} + {messages.map((message) => ( + + ))} ); }; @@ -117,7 +89,6 @@ const TurnList = React.memo( TurnListInner, (prevProps, nextProps) => sameMessages(prevProps.messages, nextProps.messages) && - prevProps.branchGroups === nextProps.branchGroups && prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speechState === nextProps.speechState && prevProps.onSpeak === nextProps.onSpeak && @@ -126,8 +97,7 @@ const TurnList = React.memo( prevProps.onStopSpeech === nextProps.onStopSpeech && prevProps.isTtsSupported === nextProps.isTtsSupported && prevProps.onRegenerate === nextProps.onRegenerate && - prevProps.onEditResubmit === nextProps.onEditResubmit && - prevProps.onCycleBranch === nextProps.onCycleBranch && + prevProps.onCreateBranch === nextProps.onCreateBranch && prevProps.onReplyPermission === nextProps.onReplyPermission, ); @@ -249,8 +219,6 @@ const EmptyState = () => { export const AgentWorkspace = ({ messages, - branchGroups, - branchTransition, isStreaming, bottomRef, speakingMessageId, @@ -261,8 +229,7 @@ export const AgentWorkspace = ({ onStopSpeech, isTtsSupported, onRegenerate, - onEditResubmit, - onCycleBranch, + onCreateBranch, onReplyPermission, }: AgentWorkspaceProps) => { const theme = useTheme(); @@ -274,18 +241,12 @@ export const AgentWorkspace = ({ (!latestAssistant || (latestAssistant.content.trim().length === 0 && !(latestAssistant.artifacts?.length))); - const stableMessages = branchTransition - ? messages.slice(0, branchTransition.parentCount) - : messages; - const transitionMessages = branchTransition - ? messages.slice(branchTransition.parentCount) - : []; const streamingMessage = - !branchTransition && isStreaming && messages.at(-1)?.role === "assistant" + isStreaming && messages.at(-1)?.role === "assistant" ? messages.at(-1) : undefined; const historyMessages = - streamingMessage !== undefined ? messages.slice(0, -1) : stableMessages; + streamingMessage !== undefined ? messages.slice(0, -1) : messages; return ( {streamingMessage ? ( ) : null} - - {branchTransition ? ( - - - - - - ) : null} ) : null} diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index a9d674c..d1d1192 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -67,15 +67,12 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { messages, chatSessions, activeSessionId, - branchGroups, - branchTransition, isHydrating, isStreaming, sessionTitle, sendPrompt, regenerate, - editAndResubmit, - cycleBranch, + createBranch, abort, replyPermission, createSession, @@ -344,8 +341,6 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { = ({ open, onClose }) => { onStopSpeech={handleStopSpeech} isTtsSupported={isTtsSupported} onRegenerate={regenerate} - onEditResubmit={editAndResubmit} - onCycleBranch={cycleBranch} + onCreateBranch={createBranch} onReplyPermission={replyPermission} /> diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index 8e29200..2ff19ff 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -55,34 +55,6 @@ export type Message = { progress?: ChatProgress[]; artifacts?: AgentArtifact[]; 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 = { @@ -106,7 +78,6 @@ export type LoadedChatState = { title?: string; isTitleManuallyEdited?: boolean; messages: Message[]; - branchGroups: BranchGroup[]; isStreaming?: boolean; runStatus?: string; }; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index eb6ccf2..4564a52 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -1,4 +1,4 @@ -import type { BranchGroup, Message } from "./GlobalChatbox.types"; +import type { Message } from "./GlobalChatbox.types"; export const createId = () => `${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 cloneBranchGroups = (branchGroups: BranchGroup[]) => - branchGroups.map((group) => ({ - ...group, - branches: group.branches.map((branch) => ({ - ...branch, - messages: cloneMessages(branch.messages), - })), - })); diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index f1117c9..1575f86 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -21,7 +21,6 @@ describe("chatStorage backend-only persistence", () => { title: undefined, messages: [], sessionId: undefined, - branchGroups: [], }); expect(apiFetch).not.toHaveBeenCalled(); }); @@ -60,11 +59,9 @@ describe("chatStorage backend-only persistence", () => { id: "message-2", role: "user", content: "第一条消息", - branchRootId: "message-2", }, ], sessionId: undefined, - branchGroups: [], }, ); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 81d5506..30ee6ff 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -2,12 +2,11 @@ import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; import type { - BranchGroup, ChatSessionSummary, LoadedChatState, Message, } from "./GlobalChatbox.types"; -import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils"; +import { cloneMessages } from "./GlobalChatbox.utils"; type BackendSessionPayload = { id?: string; @@ -23,22 +22,16 @@ export const createEmptyChatState = (): LoadedChatState => ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); const sanitizeMessages = (messages: Message[] | undefined) => Array.isArray(messages) ? cloneMessages(messages) : []; -const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => - Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; - const hasChatContent = (state: { messages: Message[]; - branchGroups: BranchGroup[]; sessionId?: string; }) => state.messages.length > 0 || - state.branchGroups.length > 0 || Boolean(state.sessionId); const compareSessionsByAnchorTime = ( @@ -107,7 +100,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], })), deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), listChatSessions: (...args: unknown[]) => listChatSessions(...args), @@ -39,7 +39,6 @@ jest.mock("../chatStorage", () => ({ isTitleManuallyEdited: false, messages: [], sessionId: "session-loaded", - branchGroups: [], })), saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), @@ -52,10 +51,12 @@ describe("useAgentChatSession", () => { saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); + jest.mocked(forkAgentChat).mockReset(); jest.mocked(replyAgentPermission).mockReset(); jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); jest.mocked(abortAgentChat).mockImplementation(async () => undefined); + jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); @@ -596,9 +597,10 @@ describe("useAgentChatSession", () => { await act(async () => { await result.current.sendPrompt("重新分析压力异常"); }); + const assistantMessageId = result.current.messages[1]?.id ?? ""; await act(async () => { - await result.current.regenerate(); + await result.current.regenerate(assistantMessageId); }); 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); + }); }); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index f046270..99dab87 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -18,15 +18,12 @@ import type { import type { AgentArtifact, AgentPermissionRequest, - BranchGroup, - BranchTransition, ChatProgress, ChatSessionSummary, LoadedChatState, Message, } from "../GlobalChatbox.types"; import { - cloneBranchGroups, cloneMessages, createId, } from "../GlobalChatbox.utils"; @@ -68,7 +65,6 @@ const createPersistedStateKey = (state: LoadedChatState) => isTitleManuallyEdited: state.isTitleManuallyEdited ?? false, sessionId: state.sessionId ?? null, messages: state.messages, - branchGroups: state.branchGroups, }); 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(); return { id, role: "user", content, - branchRootId: branchRootId ?? id, }; }; @@ -209,9 +204,6 @@ const createAssistantMessage = (): Message => ({ content: "", }); -const messagesEqual = (left: Message[], right: Message[]) => - JSON.stringify(left) === JSON.stringify(right); - export const useAgentChatSession = ({ projectId, onToolCall, @@ -226,15 +218,12 @@ export const useAgentChatSession = ({ const [sessionTitle, setSessionTitle] = useState(undefined); const [isSessionTitleManuallyEdited, setIsSessionTitleManuallyEdited] = useState(false); const [sessionId, setSessionId] = useState(undefined); - const [branchGroups, setBranchGroups] = useState([]); const [chatSessions, setChatSessions] = useState([]); - const [branchTransition, setBranchTransition] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [isHydrating, setIsHydrating] = useState(true); const abortRef = useRef(null); const sessionIdRef = useRef(undefined); const messagesRef = useRef([]); - const branchGroupsRef = useRef([]); const resumeStreamingSessionRef = useRef<((sessionId: string) => void) | null>(null); const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef | null>(null); @@ -245,7 +234,6 @@ export const useAgentChatSession = ({ title: undefined, isTitleManuallyEdited: false, messages: [], - branchGroups: [], }), ); @@ -257,9 +245,6 @@ export const useAgentChatSession = ({ messagesRef.current = messages; }, [messages]); - useEffect(() => { - branchGroupsRef.current = branchGroups; - }, [branchGroups]); useEffect(() => { isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; @@ -279,17 +264,14 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - setBranchTransition(null); setMessages([]); setSessionTitle(undefined); setIsSessionTitleManuallyEdited(false); setSessionId(undefined); - setBranchGroups([]); setChatSessions([]); setIsHydrating(false); return; @@ -313,7 +295,6 @@ export const useAgentChatSession = ({ setSessionTitle(loadedState.title); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setSessionId(loadedState.sessionId); - setBranchGroups(loadedState.branchGroups); setChatSessions(sessions); if ( loadedState.sessionId && @@ -351,7 +332,6 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: isSessionTitleManuallyEdited, messages, sessionId, - branchGroups, }; const currentStateKey = createPersistedStateKey(state); @@ -381,46 +361,7 @@ export const useAgentChatSession = ({ return () => { window.clearTimeout(persistTimer); }; - }, [branchGroups, 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]); + }, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { setMessages((prev) => @@ -479,7 +420,6 @@ export const useAgentChatSession = ({ title: nextTitle, isTitleManuallyEdited: false, messages: messagesRef.current, - branchGroups: branchGroupsRef.current, }); } if (targetSessionId) { @@ -643,7 +583,6 @@ export const useAgentChatSession = ({ await cancelPromiseRef.current?.catch(() => undefined); onBeforeSend?.(); - setBranchTransition(null); const nextUserMessage = userMessage ?? createUserMessage(prompt); const nextAssistantMessage = assistantMessage ?? createAssistantMessage(); @@ -832,7 +771,6 @@ export const useAgentChatSession = ({ const controller = abortRef.current; controller?.abort(); - setBranchTransition(null); hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = undefined; @@ -841,13 +779,11 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); setMessages([]); setSessionTitle("新对话"); setIsSessionTitleManuallyEdited(false); setSessionId(undefined); - setBranchGroups([]); setIsStreaming(false); }, [isHydrating, isStreaming]); @@ -868,12 +804,10 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); - setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); - setBranchGroups(nextState.branchGroups); setChatSessions(sessions); if (nextState.sessionId && nextState.isStreaming) { resumeStreamingSession(nextState.sessionId); @@ -917,14 +851,11 @@ export const useAgentChatSession = ({ isTitleManuallyEdited: false, messages: [], sessionId: undefined, - branchGroups: [], }); - setBranchTransition(null); setMessages([]); setSessionTitle(undefined); setIsSessionTitleManuallyEdited(false); setSessionId(undefined); - setBranchGroups([]); return; } @@ -937,12 +868,10 @@ export const useAgentChatSession = ({ titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); - setBranchTransition(null); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); setSessionId(nextState.sessionId); - setBranchGroups(nextState.branchGroups); setChatSessions(sessionsAfterDelete); } catch (error) { console.error("[GlobalChatbox] Failed to delete chat session:", error); @@ -985,183 +914,99 @@ export const useAgentChatSession = ({ title: normalizedTitle, isTitleManuallyEdited: true, messages, - branchGroups, }); } } catch (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; - let lastUserIndex = messages.length - 1; - while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") { - lastUserIndex--; + const targetAssistantIndex = messages.findIndex( + (message) => message.id === messageId && message.role === "assistant", + ); + if (targetAssistantIndex < 0) { + return; } - if (lastUserIndex < 0) return; + let targetUserIndex = targetAssistantIndex - 1; + while (targetUserIndex >= 0 && messages[targetUserIndex].role !== "user") { + targetUserIndex--; + } - const lastUser = messages[lastUserIndex]; - const lastUserContent = lastUser.content; - const nextMessages = cloneMessages(messages.slice(0, lastUserIndex)); - const nextUserMessage = createUserMessage( - lastUserContent, - lastUser.branchRootId ?? lastUser.id, - ); - const nextAssistantMessage = createAssistantMessage(); + if (targetUserIndex < 0) return; - setMessages(nextMessages); - await runPrompt({ - prompt: lastUserContent, - regenerateFromMessageIndex: lastUserIndex, - preparedMessages: [ - ...nextMessages, - nextUserMessage, - nextAssistantMessage, - ], - userMessage: nextUserMessage, - assistantMessage: nextAssistantMessage, - }); - }, [isHydrating, isStreaming, messages, runPrompt]); + const targetUser = messages[targetUserIndex]; + const targetUserContent = targetUser.content; + const nextMessages = cloneMessages(messages.slice(0, targetUserIndex)); + const nextUserMessage = createUserMessage(targetUserContent); + const nextAssistantMessage = createAssistantMessage(); - const editAndResubmit = useCallback( - async (messageId: string, newContent: string) => { + setMessages(nextMessages); + 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; - const trimmedContent = newContent.trim(); - if (!trimmedContent) return; + const assistantIndex = messages.findIndex( + (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 keepMessageCount = messageIndex; - const prefix = cloneMessages(messages.slice(0, messageIndex)); - const originalSuffix = cloneMessages(messages.slice(messageIndex)); + const keepMessageCount = assistantIndex + 1; + const copiedMessages = cloneMessages(messages.slice(0, 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; setSessionId(forkedSessionId); - await runPrompt({ - prompt: trimmedContent, - sessionIdOverride: forkedSessionId, - preparedMessages: [...prefix, ...nextSuffix], - userMessage: nextUserMessage, - assistantMessage: nextAssistantMessage, - }); - }, - [isHydrating, isStreaming, messages, runPrompt], - ); - - 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(), + messagesRef.current = copiedMessages; + setMessages(copiedMessages); + setIsSessionTitleManuallyEdited(false); + const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本"; + setSessionTitle(forkTitle); + try { + await saveActiveChatState({ + title: forkTitle, + isTitleManuallyEdited: false, + messages: copiedMessages, + sessionId: forkedSessionId, }); - sessionIdRef.current = selectedBranch.sessionId; - setSessionId(selectedBranch.sessionId); - setMessages(nextMessages); - - return next; - }); + setChatSessions(await listChatSessions()); + } catch (error) { + console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error); + } }, - [isHydrating, isStreaming, messages], + [isHydrating, isStreaming, messages, sessionTitle], ); return { messages, chatSessions, activeSessionId: sessionIdRef.current, - branchGroups, - branchTransition, isHydrating, isStreaming, sessionTitle, sessionId, sendPrompt, regenerate, - editAndResubmit, - cycleBranch, + createBranch, abort, replyPermission, createSession,