diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx index 2dbbed1176..d0c4c68509 100644 --- a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -1,13 +1,19 @@ -import { useChatContext } from '@renderer/pages/home/Messages/ChatContext' +import { useChatContext } from '@renderer/hooks/useChatContext' +import { Topic } from '@renderer/types' import { Button, Tooltip } from 'antd' import { Copy, Save, Trash, X } from 'lucide-react' import { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -const MultiSelectActionPopup: FC = () => { +interface Props { + topic: Topic +} + +const MultiSelectActionPopup: FC = ({ topic }) => { const { t } = useTranslation() - const { toggleMultiSelectMode, selectedMessageIds, isMultiSelectMode, handleMultiSelectAction } = useChatContext() + const { toggleMultiSelectMode, selectedMessageIds, isMultiSelectMode, handleMultiSelectAction } = + useChatContext(topic) const handleAction = (action: string) => { handleMultiSelectAction(action, selectedMessageIds) diff --git a/src/renderer/src/hooks/useChatContext.ts b/src/renderer/src/hooks/useChatContext.ts new file mode 100644 index 0000000000..ae67a85413 --- /dev/null +++ b/src/renderer/src/hooks/useChatContext.ts @@ -0,0 +1,185 @@ +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' +import { selectMessagesForTopic } from '@renderer/store/newMessage' +import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime' +import { Topic } from '@renderer/types' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector, useStore } from 'react-redux' + +export const useChatContext = (activeTopic: Topic) => { + const { t } = useTranslation() + const dispatch = useDispatch() + const store = useStore() + const { deleteMessage } = useMessageOperations(activeTopic) + + const [messageRefs, setMessageRefs] = useState>(new Map()) + + const isMultiSelectMode = useSelector((state: RootState) => state.runtime.chat.isMultiSelectMode) + const selectedMessageIds = useSelector((state: RootState) => state.runtime.chat.selectedMessageIds) + + useEffect(() => { + const unsubscribe = EventEmitter.on(EVENT_NAMES.CHANGE_TOPIC, () => { + dispatch(toggleMultiSelectMode(false)) + }) + return () => unsubscribe() + }, [dispatch]) + + useEffect(() => { + dispatch(setActiveTopic(activeTopic)) + }, [dispatch, activeTopic]) + + const handleToggleMultiSelectMode = useCallback( + (value: boolean) => { + dispatch(toggleMultiSelectMode(value)) + }, + [dispatch] + ) + + const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => { + setMessageRefs((prev) => { + const newRefs = new Map(prev) + if (element) { + newRefs.set(id, element) + } else { + newRefs.delete(id) + } + return newRefs + }) + }, []) + + const locateMessage = useCallback( + (messageId: string) => { + const messageElement = messageRefs.get(messageId) + if (messageElement) { + // 检查消息是否可见 + const display = window.getComputedStyle(messageElement).display + + if (display === 'none') { + // 如果消息隐藏,需要处理显示逻辑 + // 查找消息并设置为选中状态 + const state = store.getState() + const messages = selectMessagesForTopic(state, activeTopic.id) + const message = messages.find((m) => m.id === messageId) + if (message) { + // 这里需要实现设置消息为选中状态的逻辑 + // 可能需要调用其他函数或修改状态 + } + } + + // 滚动到消息位置 + messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, + [messageRefs, store, activeTopic.id] + ) + + const handleSelectMessage = useCallback( + (messageId: string, selected: boolean) => { + dispatch( + setSelectedMessageIds( + selected ? [...selectedMessageIds, messageId] : selectedMessageIds.filter((id) => id !== messageId) + ) + ) + }, + [dispatch, selectedMessageIds] + ) + + const handleMultiSelectAction = useCallback( + async (actionType: string, messageIds: string[]) => { + if (messageIds.length === 0) { + window.message.warning(t('chat.multiple.select.empty')) + return + } + + const state = store.getState() + const messages = selectMessagesForTopic(state, activeTopic.id) + const messageBlocks = messageBlocksSelectors.selectEntities(state) + + switch (actionType) { + case 'delete': + window.modal.confirm({ + title: t('message.delete.confirm.title'), + content: t('message.delete.confirm.content', { count: messageIds.length }), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + try { + await Promise.all(messageIds.map((messageId) => deleteMessage(messageId))) + window.message.success(t('message.delete.success')) + handleToggleMultiSelectMode(false) + } catch (error) { + console.error('Failed to delete messages:', error) + window.message.error(t('message.delete.failed')) + } + } + }) + break + case 'save': { + const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) + if (assistantMessages.length > 0) { + const contentToSave = assistantMessages + .map((msg) => { + return msg.blocks + .map((blockId) => { + const block = messageBlocks[blockId] + return block && 'content' in block ? block.content : '' + }) + .filter(Boolean) + .join('\n') + .trim() + }) + .join('\n\n---\n\n') + const fileName = `chat_export_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.md` + await window.api.file.save(fileName, contentToSave) + window.message.success({ content: t('message.save.success.title'), key: 'save-messages' }) + handleToggleMultiSelectMode(false) + } else { + window.message.warning(t('message.save.no.assistant')) + } + break + } + case 'copy': { + const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) + if (assistantMessages.length > 0) { + const contentToCopy = assistantMessages + .map((msg) => { + return msg.blocks + .map((blockId) => { + const block = messageBlocks[blockId] + return block && 'content' in block ? block.content : '' + }) + .filter(Boolean) + .join('\n') + .trim() + }) + .join('\n\n---\n\n') + navigator.clipboard.writeText(contentToCopy) + window.message.success({ content: t('message.copied'), key: 'copy-messages' }) + handleToggleMultiSelectMode(false) + } else { + window.message.warning(t('message.copy.no.assistant')) + } + break + } + default: + break + } + }, + [t, store, activeTopic.id, deleteMessage, handleToggleMultiSelectMode] + ) + + return { + isMultiSelectMode, + selectedMessageIds, + toggleMultiSelectMode: handleToggleMultiSelectMode, + handleMultiSelectAction, + handleSelectMessage, + activeTopic, + locateMessage, + messageRefs, + registerMessageElement + } +} diff --git a/src/renderer/src/pages/history/components/SearchMessage.tsx b/src/renderer/src/pages/history/components/SearchMessage.tsx index 3e05d53fb6..b6b7c73146 100644 --- a/src/renderer/src/pages/history/components/SearchMessage.tsx +++ b/src/renderer/src/pages/history/components/SearchMessage.tsx @@ -3,7 +3,6 @@ import { HStack } from '@renderer/components/Layout' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import { useSettings } from '@renderer/hooks/useSettings' import { getTopicById } from '@renderer/hooks/useTopic' -import { ChatProvider } from '@renderer/pages/home/Messages/ChatContext' import { default as MessageItem } from '@renderer/pages/home/Messages/Message' import { locateToMessage } from '@renderer/services/MessagesService' import NavigationService from '@renderer/services/NavigationService' @@ -43,27 +42,25 @@ const SearchMessage: FC = ({ message, ...props }) => { } return ( - - - - - - - - - - - + + + + + + + + + ) } diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index 5e905a3141..27372db4f3 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -4,7 +4,6 @@ import SearchPopup from '@renderer/components/Popups/SearchPopup' import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' -import { ChatProvider } from '@renderer/pages/home/Messages/ChatContext' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' @@ -48,35 +47,33 @@ const TopicMessages: FC = ({ topic, ...props }) => { } return ( - - - - - {topic?.messages.map((message) => ( -
- -
- ))} - {isEmpty && } - {!isEmpty && ( - - - - )} -
-
-
-
+ + + + {topic?.messages.map((message) => ( +
+ +
+ ))} + {isEmpty && } + {!isEmpty && ( + + + + )} +
+
+
) } diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 8cdd481089..966b3c83be 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -2,6 +2,7 @@ import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSea import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup' import { QuickPanelProvider } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' @@ -13,7 +14,6 @@ import { useHotkeys } from 'react-hotkeys-hook' import styled from 'styled-components' import Inputbar from './Inputbar/Inputbar' -import { ChatProvider, useChatContext } from './Messages/ChatContext' import Messages from './Messages/Messages' import Tabs from './Tabs' @@ -28,7 +28,7 @@ const ChatContent: FC = (props) => { const { assistant } = useAssistant(props.assistant.id) const { topicPosition, messageStyle, showAssistants } = useSettings() const { showTopics } = useShowTopics() - const { isMultiSelectMode } = useChatContext() + const { isMultiSelectMode } = useChatContext(props.activeTopic) const mainRef = React.useRef(null) const contentSearchRef = React.useRef(null) @@ -127,7 +127,7 @@ const ChatContent: FC = (props) => { - {isMultiSelectMode && } + {isMultiSelectMode && } {topicPosition === 'right' && showTopics && ( @@ -144,11 +144,7 @@ const ChatContent: FC = (props) => { } const Chat: FC = (props) => { - return ( - - - - ) + return } const MessagesContainer = styled.div` diff --git a/src/renderer/src/pages/home/Messages/ChatContext.tsx b/src/renderer/src/pages/home/Messages/ChatContext.tsx deleted file mode 100644 index 733cdeaa2c..0000000000 --- a/src/renderer/src/pages/home/Messages/ChatContext.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useMessageOperations } from '@renderer/hooks/useMessageOperations' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import { RootState } from '@renderer/store' -import { messageBlocksSelectors } from '@renderer/store/messageBlock' -import { selectMessagesForTopic } from '@renderer/store/newMessage' -import { Topic } from '@renderer/types' -import { createContext, FC, ReactNode, use, useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useStore } from 'react-redux' - -interface ChatContextProps { - isMultiSelectMode: boolean - selectedMessageIds: string[] - toggleMultiSelectMode: (value: boolean) => void - handleMultiSelectAction: (actionType: string, messageIds: string[]) => void - handleSelectMessage: (messageId: string, selected: boolean) => void - activeTopic: Topic - locateMessage: (messageId: string) => void - messageRefs: Map - registerMessageElement: (id: string, element: HTMLElement | null) => void -} - -interface ChatProviderProps { - children: ReactNode - activeTopic: Topic -} - -const ChatContext = createContext(undefined) - -export const useChatContext = () => { - const context = use(ChatContext) - if (!context) { - throw new Error('useChatContext 必须在 ChatProvider 内使用') - } - return context -} - -export const ChatProvider: FC = ({ children, activeTopic }) => { - const { t } = useTranslation() - const { deleteMessage } = useMessageOperations(activeTopic) - const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) - const [selectedMessageIds, setSelectedMessageIds] = useState([]) - const [messageRefs, setMessageRefs] = useState>(new Map()) - - const store = useStore() - - useEffect(() => { - const unsubscribe = EventEmitter.on(EVENT_NAMES.CHANGE_TOPIC, () => setIsMultiSelectMode(false)) - return () => unsubscribe() - }, []) - - const toggleMultiSelectMode = (value: boolean) => { - setIsMultiSelectMode(value) - if (!value) { - setSelectedMessageIds([]) - } - } - - const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => { - setMessageRefs((prev) => { - const newRefs = new Map(prev) - if (element) { - newRefs.set(id, element) - } else { - newRefs.delete(id) - } - return newRefs - }) - }, []) - - const locateMessage = (messageId: string) => { - const messageElement = messageRefs.get(messageId) - if (messageElement) { - // 检查消息是否可见 - const display = window.getComputedStyle(messageElement).display - - if (display === 'none') { - // 如果消息隐藏,需要处理显示逻辑 - // 查找消息并设置为选中状态 - const state = store.getState() - const messages = selectMessagesForTopic(state, activeTopic.id) - const message = messages.find((m) => m.id === messageId) - if (message) { - // 这里需要实现设置消息为选中状态的逻辑 - // 可能需要调用其他函数或修改状态 - } - } - - // 滚动到消息位置 - messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - } - - const handleSelectMessage = (messageId: string, selected: boolean) => { - setSelectedMessageIds((prev) => { - const newSet = new Set(prev) - if (selected) { - newSet.add(messageId) - } else { - newSet.delete(messageId) - } - return Array.from(newSet) - }) - } - - const handleMultiSelectAction = (actionType: string, messageIds: string[]) => { - if (messageIds.length === 0) { - window.message.warning(t('chat.multiple.select.empty')) - return - } - - const state = store.getState() - const messages = selectMessagesForTopic(state, activeTopic.id) - const messageBlocks = messageBlocksSelectors.selectEntities(state) - - switch (actionType) { - case 'delete': - window.modal.confirm({ - title: t('message.delete.confirm.title'), - content: t('message.delete.confirm.content', { count: messageIds.length }), - okButtonProps: { danger: true }, - centered: true, - onOk: async () => { - try { - await Promise.all(messageIds.map((messageId) => deleteMessage(messageId))) - window.message.success(t('message.delete.success')) - toggleMultiSelectMode(false) - } catch (error) { - console.error('Failed to delete messages:', error) - window.message.error(t('message.delete.failed')) - } - } - }) - break - case 'save': { - const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) - if (assistantMessages.length > 0) { - const contentToSave = assistantMessages - .map((msg) => { - return msg.blocks - .map((blockId) => { - const block = messageBlocks[blockId] - return block && 'content' in block ? block.content : '' - }) - .filter(Boolean) - .join('\n') - .trim() - }) - .join('\n\n---\n\n') - const fileName = `chat_export_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.md` - window.api.file.save(fileName, contentToSave) - window.message.success({ content: t('message.save.success.title'), key: 'save-messages' }) - toggleMultiSelectMode(false) - } else { - window.message.warning(t('message.save.no.assistant')) - } - break - } - case 'copy': { - const assistantMessages = messages.filter((msg) => messageIds.includes(msg.id)) - if (assistantMessages.length > 0) { - const contentToCopy = assistantMessages - .map((msg) => { - return msg.blocks - .map((blockId) => { - const block = messageBlocks[blockId] - return block && 'content' in block ? block.content : '' - }) - .filter(Boolean) - .join('\n') - .trim() - }) - .join('\n\n---\n\n') - navigator.clipboard.writeText(contentToCopy) - window.message.success({ content: t('message.copied'), key: 'copy-messages' }) - toggleMultiSelectMode(false) - } else { - window.message.warning(t('message.copy.no.assistant')) - } - break - } - default: - break - } - } - - const value = { - isMultiSelectMode, - selectedMessageIds, - toggleMultiSelectMode, - handleMultiSelectAction, - handleSelectMessage, - activeTopic, - locateMessage, - messageRefs, - registerMessageElement - } - - return {children} -} diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 65ac5fb6fa..86b20d8266 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -162,6 +162,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElem {messageContent} diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 2678e6ef21..d1f575c094 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -3,6 +3,7 @@ import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { TranslateLanguageOptions } from '@renderer/config/translate' import { useMessageEditing } from '@renderer/context/MessageEditingContext' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle } from '@renderer/services/MessagesService' @@ -33,8 +34,6 @@ import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import styled from 'styled-components' -import { useChatContext } from './ChatContext' - interface Props { message: Message assistant: Assistant @@ -52,7 +51,7 @@ const MessageMenubar: FC = (props) => { const { message, index, isGrouped, isLastMessage, isAssistantMessage, assistant, topic, model, messageContainerRef } = props const { t } = useTranslation() - const { toggleMultiSelectMode } = useChatContext() + const { toggleMultiSelectMode } = useChatContext(props.topic) const [copied, setCopied] = useState(false) const [isTranslating, setIsTranslating] = useState(false) const [showRegenerateTooltip, setShowRegenerateTooltip] = useState(false) diff --git a/src/renderer/src/pages/home/Messages/MessageSelect.tsx b/src/renderer/src/pages/home/Messages/MessageSelect.tsx index 3a4549d7a3..88ffeb7f41 100644 --- a/src/renderer/src/pages/home/Messages/MessageSelect.tsx +++ b/src/renderer/src/pages/home/Messages/MessageSelect.tsx @@ -1,23 +1,24 @@ +import { useChatContext } from '@renderer/hooks/useChatContext' +import { Topic } from '@renderer/types' import { Checkbox } from 'antd' import { FC, ReactNode, useEffect, useRef } from 'react' import styled from 'styled-components' -import { useChatContext } from './ChatContext' - interface SelectableMessageProps { children: ReactNode messageId: string + topic: Topic isClearMessage?: boolean } -const SelectableMessage: FC = ({ children, messageId, isClearMessage = false }) => { +const SelectableMessage: FC = ({ children, messageId, topic, isClearMessage = false }) => { const containerRef = useRef(null) const { registerMessageElement: contextRegister, isMultiSelectMode, selectedMessageIds, handleSelectMessage - } = useChatContext() + } = useChatContext(topic) const isSelected = selectedMessageIds?.includes(messageId) diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 7fa4cc4462..402fb0b5fc 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -2,6 +2,7 @@ import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessageOperations' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' @@ -32,7 +33,6 @@ import { useTranslation } from 'react-i18next' import InfiniteScroll from 'react-infinite-scroll-component' import styled from 'styled-components' -import { useChatContext } from './ChatContext' import ChatNavigation from './ChatNavigation' import MessageAnchorLine from './MessageAnchorLine' import MessageGroup from './MessageGroup' @@ -53,7 +53,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o ) const { t } = useTranslation() const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() - const { isMultiSelectMode, handleSelectMessage } = useChatContext() + const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic) const { updateTopic, addTopic } = useAssistant(assistant.id) const dispatch = useAppDispatch() const [displayMessages, setDisplayMessages] = useState([]) diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index d3820ef8d2..207003127c 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -1,7 +1,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AppLogo, UserAvatar } from '@renderer/config/env' -import type { MinAppType } from '@renderer/types' +import type { MinAppType, Topic } from '@renderer/types' import type { UpdateInfo } from 'builder-util-runtime' + +export interface ChatState { + isMultiSelectMode: boolean + selectedMessageIds: string[] + activeTopic: Topic | null +} + export interface UpdateState { info: UpdateInfo | null checking: boolean @@ -27,6 +34,7 @@ export interface RuntimeState { resourcesPath: string update: UpdateState export: ExportState + chat: ChatState } export interface ExportState { @@ -53,6 +61,11 @@ const initialState: RuntimeState = { }, export: { isExporting: false + }, + chat: { + isMultiSelectMode: false, + selectedMessageIds: [], + activeTopic: null } } @@ -92,6 +105,19 @@ const runtimeSlice = createSlice({ }, setExportState: (state, action: PayloadAction>) => { state.export = { ...state.export, ...action.payload } + }, + // Chat related actions + toggleMultiSelectMode: (state, action: PayloadAction) => { + state.chat.isMultiSelectMode = action.payload + if (!action.payload) { + state.chat.selectedMessageIds = [] + } + }, + setSelectedMessageIds: (state, action: PayloadAction) => { + state.chat.selectedMessageIds = action.payload + }, + setActiveTopic: (state, action: PayloadAction) => { + state.chat.activeTopic = action.payload } } }) @@ -107,7 +133,11 @@ export const { setFilesPath, setResourcesPath, setUpdateState, - setExportState + setExportState, + // Chat related actions + toggleMultiSelectMode, + setSelectedMessageIds, + setActiveTopic } = runtimeSlice.actions export default runtimeSlice.reducer