diff --git a/src/renderer/src/hooks/useMessages.v2.ts b/src/renderer/src/hooks/useMessages.v2.ts new file mode 100644 index 0000000000..0ea89711d4 --- /dev/null +++ b/src/renderer/src/hooks/useMessages.v2.ts @@ -0,0 +1,412 @@ +/** + * @fileoverview Message UI data hooks for v2 architecture migration + * + * This module provides hooks for fetching and managing message data: + * - {@link useTopicMessagesFromApi} - Fetch messages via DataApi with infinite scroll + * - {@link useStreamingSessionIds} - Get active streaming session IDs for a topic + * - {@link useTopicMessagesUnified} - Unified entry point with Agent Session routing + * + * ## Architecture Overview + * + * Two data paths, same processing logic: + * ``` + * ┌─────────────────────────────────────────────────────────────┐ + * │ Data Sources │ + * ├─────────────────────────────┬───────────────────────────────┤ + * │ DataApi │ Streaming │ + * │ useTopicMessagesFromApi() │ useCache(session.${id}) │ + * │ (direct hook usage) │ (component-level subscribe) │ + * └─────────────────────────────┴───────────────────────────────┘ + * ↓ ↓ + * { message, blocks } { message, blocks } + * ↓ ↓ + * └───────────┬─────────────┘ + * ↓ + * ┌─────────────────────────────────────────────────────────────┐ + * │ Unified UI Processing │ + * │ Grouping (askId/parentId) → MessageGroup → MessageItem │ + * └─────────────────────────────────────────────────────────────┘ + * ``` + * + * ## Type Conversion + * + * DataApi returns `shared` format, but UI expects `renderer` format. + * Conversion is performed at the hook layer using `convertToRendererFormat`. + * + * | Shared (DataApi) | Renderer (UI) | + * |---------------------------|-----------------------------| + * | message.data.blocks[] | message.blocks: string[] | + * | message.parentId | message.askId | + * | message.stats | message.usage + metrics | + * | MessageDataBlock | MessageBlock | + * + * NOTE: [v2 Migration] These conversions will be removed when UI components + * are migrated to use shared types directly. + */ + +import { useCache } from '@data/hooks/useCache' +import { useInfiniteQuery } from '@data/hooks/useDataApi' +import { LOAD_MORE_COUNT } from '@renderer/config/constant' +import { useAppSelector } from '@renderer/store' +import type { Message, MessageBlock, MessageBlockType } from '@renderer/types/newMessage' +import { MessageBlockStatus } from '@renderer/types/newMessage' +import { isAgentSessionTopicId } from '@renderer/utils/agentSession' +import type { BranchMessage, Message as SharedMessage, MessageDataBlock } from '@shared/data/types/message' +import { useCallback, useMemo } from 'react' + +import { selectNewDisplayCount, useTopicMessages } from './useMessageOperations' + +// Re-export for convenience +export type { BranchMessage, SharedMessage } + +// ============================================================================ +// Type Conversion Functions +// ============================================================================ + +/** + * Converts a shared MessageDataBlock to renderer MessageBlock format. + * + * Key differences: + * - Renderer blocks have `id`, `status`, `messageId` (not in shared format) + * - Block types are structurally identical but enum values differ + * + * TRADEOFF: Using 'as unknown as' for type and status fields + * because renderer's MessageBlockType/MessageBlockStatus and shared's BlockType + * are structurally identical but TypeScript treats them as incompatible enums. + * + * @param block - Block in shared format from DataApi + * @param messageId - Parent message ID for the block + * @param index - Block index (used to generate temporary ID) + * @returns Block in renderer format + */ +function convertBlock(block: MessageDataBlock, messageId: string, index: number): MessageBlock { + // Generate temporary ID: messageId#index + // NOTE: [v2 Migration] Block IDs are not persisted in DataApi. + // Using messageId#index for React key purposes. This is stable + // because blocks are only appended, never reordered or deleted mid-array. + const id = `${messageId}#${index}` + + // Extract common fields, excluding shared-specific ones we'll override + // oxlint-disable-next-line @typescript-eslint/no-unused-vars + const { type, createdAt, updatedAt, metadata, error, ...restBlock } = block + + return { + ...restBlock, + id, + messageId, + type: type as unknown as MessageBlockType, + createdAt: typeof createdAt === 'number' ? new Date(createdAt).toISOString() : String(createdAt), + updatedAt: updatedAt + ? typeof updatedAt === 'number' + ? new Date(updatedAt).toISOString() + : String(updatedAt) + : undefined, + status: MessageBlockStatus.SUCCESS, + metadata, + error + } as MessageBlock +} + +/** + * Converts a shared Message to renderer format with blocks. + * + * Key field mappings: + * - shared.parentId → renderer.askId (for backward compatibility) + * - shared.stats → renderer.usage + renderer.metrics (split into two objects) + * - shared.data.blocks → converted via convertBlock() + * + * NOTE: [v2 Migration] This conversion preserves the old renderer format + * for backward compatibility with existing UI components. When UI is migrated + * to use shared types, this function will be simplified or removed. + * + * @param shared - Message in shared format from DataApi + * @returns Object containing message in renderer format and block array + */ +export function convertToRendererFormat(shared: SharedMessage): { + message: Message + blocks: MessageBlock[] +} { + // Convert blocks: MessageDataBlock[] → MessageBlock[] + const blocks = shared.data.blocks.map((block, index) => convertBlock(block, shared.id, index)) + + // Convert stats to usage and metrics (split format) + // Only create if all required fields are present + const stats = shared.stats + const usage = + stats?.promptTokens !== undefined && stats?.completionTokens !== undefined && stats?.totalTokens !== undefined + ? { + prompt_tokens: stats.promptTokens, + completion_tokens: stats.completionTokens, + total_tokens: stats.totalTokens + } + : undefined + + // Metrics requires completion_tokens and time_completion_millsec + const metrics = + stats?.completionTokens !== undefined && stats?.timeCompletionMs !== undefined + ? { + completion_tokens: stats.completionTokens, + time_completion_millsec: stats.timeCompletionMs, + time_first_token_millsec: stats.timeFirstTokenMs + } + : undefined + + // Build renderer Message format + const message: Message = { + id: shared.id, + topicId: shared.topicId, + role: shared.role, + assistantId: shared.assistantId ?? '', + status: shared.status as Message['status'], + createdAt: shared.createdAt, + updatedAt: shared.updatedAt, + // NOTE: [v2 Migration] blocks field stores IDs for Redux compatibility + // New path passes block objects directly via separate blocks array + blocks: blocks.map((b) => b.id), + // v2 parentId → v1 askId mapping + askId: shared.parentId ?? undefined, + modelId: shared.modelId ?? undefined, + traceId: shared.traceId ?? undefined, + usage, + metrics + // TODO: [v2] Add model, mentions, enabledMCPs when available in shared format + } + + return { message, blocks } +} + +// ============================================================================ +// Grouped Message Type +// ============================================================================ + +/** + * A group of messages with their blocks. + * + * For single-model responses: Array contains one item. + * For multi-model responses: Array contains all sibling responses. + */ +export interface MessageWithBlocks { + message: Message + blocks: MessageBlock[] +} + +// ============================================================================ +// DataApi Hook +// ============================================================================ + +interface UseTopicMessagesFromApiOptions { + /** Items per page (default: LOAD_MORE_COUNT) */ + limit?: number + /** Disable fetching (default: true) */ + enabled?: boolean +} + +interface UseTopicMessagesFromApiResult { + /** Grouped messages - each sub-array is a group (single or multi-model) */ + groupedMessages: MessageWithBlocks[][] + /** Active node ID from the latest page */ + activeNodeId: string | null + /** True during initial load */ + isLoading: boolean + /** True if more pages are available */ + hasMore: boolean + /** Load the next page */ + loadMore: () => void + /** Revalidate all loaded pages */ + refresh: () => void + /** SWR mutate function for cache control */ + mutate: () => Promise +} + +/** + * Fetches messages for a topic via DataApi with infinite scroll support. + * + * Features: + * - Cursor-based pagination (loads older messages towards root) + * - Automatic multi-model grouping via siblingsGroup field + * - Type conversion from shared to renderer format + * + * @param topicId - Topic ID to fetch messages for + * @param options - Fetch options + * @returns Messages grouped by sibling relationships + * + * @example + * ```typescript + * const { groupedMessages, hasMore, loadMore, isLoading } = + * useTopicMessagesFromApi(topic.id) + * + * // groupedMessages structure: + * // Single model: [[{ message, blocks }]] + * // Multi model: [[{ message, blocks }, { message, blocks }, ...]] + * ``` + */ +export function useTopicMessagesFromApi( + topicId: string, + options?: UseTopicMessagesFromApiOptions +): UseTopicMessagesFromApiResult { + const limit = options?.limit ?? LOAD_MORE_COUNT + const enabled = options?.enabled !== false + + // Use cursor-based infinite query + // Path matches MessageSchemas['/topics/:topicId/messages'].GET + const { items, isLoading, hasNext, loadNext, refresh, mutate } = useInfiniteQuery( + `/topics/${topicId}/messages` as const, + { + limit, + enabled + } + ) + + // Transform BranchMessage[] to grouped MessageWithBlocks[][] + // API already handles multi-model grouping via siblingsGroup field + const groupedMessages = useMemo(() => { + if (!items?.length) return [] + + return (items as BranchMessage[]).map((item) => { + // Convert main message + const main = convertToRendererFormat(item.message) + + // Convert siblings if present (multi-model response) + const siblings = (item.siblingsGroup || []).map((m) => convertToRendererFormat(m)) + + // Return as group: [main, ...siblings] + return [main, ...siblings] + }) + }, [items]) + + // Extract activeNodeId from latest page metadata + // NOTE: [v2] activeNodeId is in the response but useInfiniteQuery + // only exposes flattened items. We could enhance useInfiniteQuery + // to preserve metadata, but for now this is not critical. + const activeNodeId: string | null = null // TODO: [v2] Extract from raw response + + // Wrap mutate to return Promise + const wrappedMutate = useCallback(async () => { + await mutate() + }, [mutate]) + + return { + groupedMessages, + activeNodeId, + isLoading, + hasMore: hasNext, + loadMore: loadNext, + refresh, + mutate: wrappedMutate + } +} + +// ============================================================================ +// Streaming Session Hooks +// ============================================================================ + +/** + * Gets active streaming session IDs for a topic. + * + * This hook subscribes to the topic's session index in cache. + * Use in parent component to render StreamingMessageItem.v2 for each session. + * + * @param topicId - Topic ID to get streaming sessions for + * @returns Array of active message IDs (streaming session IDs) + * + * @example + * ```typescript + * const sessionIds = useStreamingSessionIds(topic.id) + * + * return ( + * <> + * {sessionIds.map(id => ( + * + * ))} + * + * ) + * ``` + */ +export function useStreamingSessionIds(topicId: string): string[] { + // Uses template key: 'message.streaming.topic_sessions.${topicId}' + const cacheKey = `message.streaming.topic_sessions.${topicId}` as const + const [sessionIds] = useCache(cacheKey, []) + return sessionIds +} + +// ============================================================================ +// Unified Entry Point +// ============================================================================ + +interface UseTopicMessagesUnifiedResult { + /** Source of the data */ + source: 'dataapi' | 'legacy' + /** For DataApi: grouped messages */ + groupedMessages?: MessageWithBlocks[][] + /** For legacy: flat message array */ + messages?: Message[] + /** Loading state */ + isLoading: boolean + /** Has more pages (DataApi only) */ + hasMore?: boolean + /** Load more function (DataApi only) */ + loadMore?: () => void + /** Refresh function */ + refresh?: () => void + /** Mutate function (DataApi only) */ + mutate?: () => Promise + /** Display count (legacy only) */ + displayCount?: number +} + +/** + * Unified entry point for topic messages with Agent Session routing. + * + * Routes to appropriate data source based on topic type: + * - Normal topics → DataApi + Cache (new architecture) + * - Agent sessions → Redux/Dexie (legacy, temporary) + * + * TRADEOFF: Maintaining two paths during migration + * - Pros: Incremental migration, no breaking changes to Agent Sessions + * - Cons: Code duplication, increased complexity + * - Plan: Remove legacy path after Agent Session migration to DataApi + * + * @param topicId - Topic ID to fetch messages for + * @returns Messages from appropriate source with source indicator + * + * @example + * ```typescript + * const result = useTopicMessagesUnified(topic.id) + * + * if (result.source === 'dataapi') { + * // Use result.groupedMessages + * } else { + * // Use result.messages (legacy) + * } + * ``` + */ +export function useTopicMessagesUnified(topicId: string): UseTopicMessagesUnifiedResult { + const isAgent = isAgentSessionTopicId(topicId) + + // Always call both hooks (React rules), but only one will be enabled + const dataApiResult = useTopicMessagesFromApi(topicId, { enabled: !isAgent }) + const legacyMessages = useTopicMessages(topicId) + // TODO: [v2] displayCount is from Redux, used for legacy path pagination + const displayCount = useAppSelector(selectNewDisplayCount) + + if (isAgent) { + // TODO: [v2] Migrate Agent Sessions to DataApi + // Currently using Redux/Dexie for Agent Session message storage + return { + source: 'legacy', + messages: legacyMessages, + isLoading: false, + displayCount + } + } + + return { + source: 'dataapi', + groupedMessages: dataApiResult.groupedMessages, + isLoading: dataApiResult.isLoading, + hasMore: dataApiResult.hasMore, + loadMore: dataApiResult.loadMore, + refresh: dataApiResult.refresh, + mutate: dataApiResult.mutate + } +} diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index d2771b36f6..06d03a3d5e 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -58,7 +58,11 @@ const AnimatedBlockWrapper: React.FC = ({ children, e } interface Props { - blocks: string[] // 可以接收块ID数组或MessageBlock数组 + // NOTE: [v2 Migration] Supports two formats for phased Redux removal: + // - string[]: Block IDs for Redux path (Agent Sessions, legacy code) + // - MessageBlock[]: Direct block objects for DataApi/Streaming path + // TODO: [v2] Remove string[] support after Agent Session migration + blocks: string[] | MessageBlock[] messageStatus?: Message['status'] message: Message } @@ -102,10 +106,28 @@ const groupSimilarBlocks = (blocks: MessageBlock[]): (MessageBlock[] | MessageBl } const MessageBlockRenderer: React.FC = ({ blocks, message }) => { - // 始终调用useSelector,避免条件调用Hook + // Keep Redux selector at top level (required by Hooks rules) + // TODO: [v2] Remove this selector after Agent Session migration const blockEntities = useSelector((state: RootState) => messageBlocksSelectors.selectEntities(state)) - // 根据blocks类型处理渲染数据 - const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean) + + // TRADEOFF: Phased Redux removal + // - New path (DataApi/Streaming): Receives MessageBlock[], uses directly + // - Old path (Agent Sessions): Receives string[], fetches from Redux + // This allows incremental migration without breaking existing functionality + const renderedBlocks = useMemo(() => { + if (blocks.length === 0) return [] + + // Check first element to determine path + if (typeof blocks[0] === 'object') { + // New path: MessageBlock[] - use directly, no Redux + return blocks as MessageBlock[] + } + + // Old path: string[] - fetch from Redux store + // TODO: [v2] Remove this branch after Agent Session migration + return (blocks as string[]).map((blockId) => blockEntities[blockId]).filter(Boolean) as MessageBlock[] + }, [blocks, blockEntities]) + const groupedBlocks = useMemo(() => groupSimilarBlocks(renderedBlocks), [renderedBlocks]) // Check if message is still processing diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 81c3dd335a..81ab168c18 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -32,6 +32,7 @@ import MessageOutline from './MessageOutline' interface Props { message: Message + blocks?: MessageBlock[] // NOTE: [v2 Migration] blocks prop allows passing block objects directly instead of relying on Redux. Used for DataApi and Streaming paths. topic: Topic assistant?: Assistant index?: number @@ -59,6 +60,7 @@ const WrapperContainer = ({ const MessageItem: FC = ({ message, + blocks, topic, // assistant, index, @@ -230,7 +232,7 @@ const MessageItem: FC = ({ overflowY: 'visible' }}> - + {showMenubar && ( diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 31cf9aa0ed..7562165b2d 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -1,6 +1,6 @@ import { Flex } from '@cherrystudio/ui' import { getModelUniqId } from '@renderer/services/ModelService' -import type { Message } from '@renderer/types/newMessage' +import type { Message, MessageBlock } from '@renderer/types/newMessage' import { isEmpty } from 'lodash' import React from 'react' import styled from 'styled-components' @@ -8,9 +8,10 @@ import styled from 'styled-components' import MessageBlockRenderer from './Blocks' interface Props { message: Message + blocks?: MessageBlock[] } -const MessageContent: React.FC = ({ message }) => { +const MessageContent: React.FC = ({ message, blocks }) => { return ( <> {!isEmpty(message.mentions) && ( @@ -20,7 +21,10 @@ const MessageContent: React.FC = ({ message }) => { ))} )} - + {/* NOTE: [v2 Migration] blocks prop takes priority over message.blocks. + When blocks is provided (from DataApi/Streaming), use it directly. + Otherwise fall back to message.blocks (string[] for Redux path). */} + ) } diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 00e1a8fa49..fda64113a2 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -7,7 +7,7 @@ import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useTimer } from '@renderer/hooks/useTimer' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { Topic } from '@renderer/types' -import type { Message } from '@renderer/types/newMessage' +import type { Message, MessageBlock } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' import { scrollIntoView } from '@renderer/utils/dom' import type { MultiModelMessageStyle } from '@shared/data/preference/preferenceTypes' @@ -23,11 +23,12 @@ import MessageGroupMenuBar from './MessageGroupMenuBar' const logger = loggerService.withContext('MessageGroup') interface Props { messages: (Message & { index: number })[] + blocksMap?: Record // NOTE: [v2 Migration] Block objects for DataApi path topic: Topic registerMessageElement?: (id: string, element: HTMLElement | null) => void } -const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { +const MessageGroup = ({ messages, blocksMap, topic, registerMessageElement }: Props) => { const messageLength = messages.length // Hooks @@ -201,9 +202,12 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { const renderMessage = useCallback( (message: Message & { index: number }) => { const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped + // NOTE: [v2 Migration] Get blocks from blocksMap for DataApi path + const blocks = blocksMap?.[message.id] const messageProps = { isGrouped, message, + blocks, // NOTE: [v2 Migration] Pass block objects directly for DataApi path topic, index: message.index } satisfies ComponentProps @@ -258,6 +262,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => { [ isGrid, isGrouped, + blocksMap, topic, multiModelMessageStyle, messages, diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 12e3e04988..7b71097782 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -6,6 +6,8 @@ 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' +// NOTE: [v2 Migration] Import unified hook and streaming session hook for DataApi path +import { useStreamingSessionIds, useTopicMessagesUnified } from '@renderer/hooks/useMessages.v2' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useShortcut } from '@renderer/hooks/useShortcuts' import { useTimer } from '@renderer/hooks/useTimer' @@ -14,6 +16,8 @@ import SelectionBox from '@renderer/pages/home/Messages/SelectionBox' import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getContextCount, getGroupedMessages, getUserMessage } from '@renderer/services/MessagesService' +// NOTE: [v2 Migration] Import streaming service for cache clearing +import { streamingService } from '@renderer/services/messageStreaming/StreamingService' import { estimateHistoryTokens } from '@renderer/services/TokenService' import store, { useAppDispatch } from '@renderer/store' import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock' @@ -42,6 +46,8 @@ import MessageGroup from './MessageGroup' import NarrowLayout from './NarrowLayout' import Prompt from './Prompt' import { MessagesContainer, ScrollContainer } from './shared' +// NOTE: [v2 Migration] Import streaming message component for DataApi path +import StreamingMessageItem from './StreamingMessageItem.v2' interface MessagesProps { assistant: Assistant @@ -67,7 +73,19 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o const [messageNavigation] = usePreference('chat.message.navigation_mode') const { t } = useTranslation() const dispatch = useAppDispatch() + + // NOTE: [v2 Migration] Use unified hook for routing between DataApi and legacy paths. + // For normal topics: uses DataApi with grouped messages + // For Agent Sessions: uses legacy Redux path (temporary until Agent Session migration) + const unifiedResult = useTopicMessagesUnified(topic.id) + const isDataApiPath = unifiedResult.source === 'dataapi' + + // Legacy path: still uses useTopicMessages for Agent Sessions const messages = useTopicMessages(topic.id) + + // DataApi path: get streaming session IDs for rendering + const sessionIds = useStreamingSessionIds(topic.id) + const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) const { setTimeoutTimer } = useTimer() @@ -88,11 +106,46 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o } }, []) + // NOTE: [v2 Migration] For legacy path only: compute display messages from Redux useEffect(() => { - const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount) - setDisplayMessages(newDisplayMessages) - setHasMore(messages.length > displayCount) - }, [messages, displayCount]) + if (!isDataApiPath) { + const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount) + setDisplayMessages(newDisplayMessages) + setHasMore(messages.length > displayCount) + } + }, [messages, displayCount, isDataApiPath]) + + // NOTE: [v2 Migration] For DataApi path: filter out groups that contain streaming messages + // This prevents duplicate rendering (streaming messages are rendered separately) + const filteredApiGroups = useMemo(() => { + if (!isDataApiPath || !unifiedResult.groupedMessages) return [] + const streamingSet = new Set(sessionIds) + return unifiedResult.groupedMessages.filter((group) => !group.some((item) => streamingSet.has(item.message.id))) + }, [isDataApiPath, unifiedResult.groupedMessages, sessionIds]) + + // NOTE: [v2 Migration] Listen for STREAMING_FINALIZED event to handle DataApi refresh and cache clearing. + // TRADEOFF: Event-driven ensures mutate() completes before cache clears, preventing UI flicker. + useEffect(() => { + if (!isDataApiPath) return + + const handler = async ({ messageId, topicId: eventTopicId }: { messageId: string; topicId: string }) => { + if (eventTopicId !== topic.id) return + + // Wait for DataApi to refresh (mutate triggers SWR revalidation) + if (unifiedResult.mutate) { + await unifiedResult.mutate() + } + + // After data is refreshed, clear the streaming cache + streamingService.clearSession(messageId) + } + + EventEmitter.on(EVENT_NAMES.STREAMING_FINALIZED, handler) + return () => { + EventEmitter.off(EVENT_NAMES.STREAMING_FINALIZED, handler) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutate is stable (useCallback wrapped), avoid re-renders from unifiedResult object reference changes + }, [isDataApiPath, topic.id, unifiedResult.mutate]) // NOTE: 如果设置为平滑滚动会导致滚动条无法跟随生成的新消息保持在底部位置 const scrollToBottom = useCallback(() => { @@ -287,8 +340,36 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o requestAnimationFrame(() => onComponentUpdate?.()) }, [onComponentUpdate]) - // NOTE: 因为displayMessages是倒序的,所以得到的groupedMessages每个group内部也是倒序的,需要再倒一遍 + // NOTE: [v2 Migration] groupedMessages computation handles both DataApi and legacy paths. + // - DataApi path: uses filteredApiGroups (already grouped by API, just needs format conversion) + // - Legacy path: uses displayMessages with getGroupedMessages (existing logic) + // TRADEOFF: DataApi path returns object with blocksMap, legacy path returns tuple for compatibility. + // This avoids breaking changes while enabling block data flow for DataApi path. const groupedMessages = useMemo(() => { + if (isDataApiPath) { + // DataApi path: convert MessageWithBlocks[][] to object format with blocksMap + // Key is the first message's ID in each group + // NOTE: [v2 Migration] MessageGroup requires index property for message navigation. + // For DataApi path, we calculate index based on position in the flattened list. + let globalIndex = 0 + return filteredApiGroups.map((group) => { + const key = group[0]?.message.id ?? '' + const messages: (Message & { index: number })[] = [] + const blocksMap: Record = {} + + group.forEach((item) => { + const msg = { ...item.message, index: globalIndex } + messages.push(msg) + blocksMap[msg.id] = item.blocks // NOTE: [v2 Migration] Preserve blocks for DataApi path + globalIndex++ + }) + + return { key, messages, blocksMap } + }) + } + + // Legacy path: original logic for Agent Sessions + // NOTE: 因为displayMessages是倒序的,所以得到的groupedMessages每个group内部也是倒序的,需要再倒一遍 const grouped = Object.entries(getGroupedMessages(displayMessages)) const newGrouped: { [key: string]: (Message & { @@ -298,8 +379,22 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o grouped.forEach(([key, group]) => { newGrouped[key] = group.toReversed() }) - return Object.entries(newGrouped) - }, [displayMessages]) + // Legacy path: return object format for consistency (blocksMap undefined) + return Object.entries(newGrouped).map(([key, messages]) => ({ key, messages, blocksMap: undefined })) + }, [isDataApiPath, filteredApiGroups, displayMessages]) + + // NOTE: [v2 Migration] Compute infinite scroll props based on path + const infiniteScrollProps = isDataApiPath + ? { + dataLength: filteredApiGroups.length, + next: unifiedResult.loadMore ?? (() => {}), + hasMore: unifiedResult.hasMore ?? false + } + : { + dataLength: displayMessages.length, + next: loadMoreMessages, + hasMore: hasMore + } return ( = ({ assistant, topic, setActiveTopic, o onScroll={handleScrollPosition}> - {groupedMessages.map(([key, groupMessages]) => ( + {/* NOTE: [v2 Migration] Render streaming messages first (at top) for DataApi path */} + {isDataApiPath && + sessionIds.map((id) => )} + {/* Render grouped messages (both DataApi and legacy paths) */} + {groupedMessages.map(({ key, messages: groupMessages, blocksMap }) => ( @@ -338,6 +438,9 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o {showPrompt && } + {/* TODO: [v2 Migration] MessageAnchorLine only works for legacy path (Agent Sessions). + For DataApi path, displayMessages is empty because it's only populated in the legacy useEffect. + To fix: flatten filteredApiGroups into a messages array for DataApi path. */} {messageNavigation === 'anchor' && } 0): + * - Multiple sessions share same parentId + siblingsGroupId + * - Each renders independently in this component + * - UI styling (horizontal layout, comparison view) handled by CSS + * - TODO: [v2] Consider caching group metadata for shared styling + */ + +import { useCache } from '@data/hooks/useCache' +import { MessageEditingProvider } from '@renderer/context/MessageEditingContext' +import type { Topic } from '@renderer/types' +import type { Message, MessageBlock } from '@renderer/types/newMessage' +import React, { memo } from 'react' + +import MessageItem from './Message' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Streaming session data structure (matches StreamingService.StreamingSession) + * + * NOTE: [v2 Migration] Using 'any' type because StreamingSession is defined + * locally in StreamingService.ts and uses renderer Message/MessageBlock types. + * Type safety is maintained by the shape we expect from the cache. + */ +interface StreamingSessionData { + topicId: string + messageId: string + message: Message + blocks: Record + parentId: string + siblingsGroupId: number + startedAt: number +} + +interface Props { + /** Message ID that identifies the streaming session */ + messageId: string + /** Current topic */ + topic: Topic + /** Index in the message list (for display purposes) */ + index?: number +} + +// ============================================================================ +// Component +// ============================================================================ + +/** + * Renders a single streaming message by subscribing to its cache key. + * + * This component: + * 1. Subscribes to `message.streaming.session.${messageId}` cache key + * 2. Re-renders when the session data updates (new blocks, content changes) + * 3. Extracts message and blocks from session + * 4. Renders using the same MessageItem component as API messages + * + * @example + * ```tsx + * // In parent component + * const sessionIds = useStreamingSessionIds(topic.id) + * + * return ( + * <> + * {sessionIds.map(id => ( + * + * ))} + * + * ) + * ``` + */ +const StreamingMessageItem: React.FC = ({ messageId, topic, index }) => { + // Subscribe to this message's streaming session + // Uses template key: 'message.streaming.session.${messageId}' + const cacheKey = `message.streaming.session.${messageId}` as const + const [session] = useCache(cacheKey, null) + + // Session not ready or cleared + if (!session) { + return null + } + + // Type assertion: session matches StreamingSessionData shape + const sessionData = session as StreamingSessionData + + // Extract message and blocks from session + const { message, blocks } = sessionData + + // Convert blocks record to array + // NOTE: [v2 Migration] StreamingService stores blocks as Record + // MessageItem expects either string[] (Redux) or MessageBlock[] (direct) + // We pass the array directly (new path) to bypass Redux + const blockArray = Object.values(blocks) + + return ( + + 0 + // isGrouped styling is handled by MessageItem based on this + isGrouped={sessionData.siblingsGroupId > 0} + /> + + ) +} + +// Memoize to prevent unnecessary re-renders from parent +// The component will re-render when its cache subscription updates +export default memo(StreamingMessageItem) diff --git a/src/renderer/src/services/EventService.ts b/src/renderer/src/services/EventService.ts index 35636dc016..e22aa08dac 100644 --- a/src/renderer/src/services/EventService.ts +++ b/src/renderer/src/services/EventService.ts @@ -25,5 +25,8 @@ export const EVENT_NAMES = { RESEND_MESSAGE: 'RESEND_MESSAGE', SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR', EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK', - CHANGE_TOPIC: 'CHANGE_TOPIC' + CHANGE_TOPIC: 'CHANGE_TOPIC', + // NOTE: [v2 Migration] Used for streaming->API transition. + // Emitted by StreamingService.finalize() to signal UI hooks to refresh data and clear cache. + STREAMING_FINALIZED: 'streaming:finalized' } diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts index 8707b18a0c..4196c16225 100644 --- a/src/renderer/src/services/messageStreaming/StreamingService.ts +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -22,6 +22,7 @@ import { cacheService } from '@data/CacheService' import { dataApiService } from '@data/DataApiService' import { loggerService } from '@logger' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import type { Model } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage' @@ -219,7 +220,20 @@ class StreamingService { await dataApiService.patch(`/messages/${session.messageId}`, { body: dataApiPayload }) } - this.clearSession(messageId) + // NOTE: [v2 Migration] Event-driven clearing for normal topics. + // TRADEOFF: Agent sessions clear immediately vs normal topics use event-driven clearing. + // Event-driven ensures DataApi has refreshed (via mutate) before cache clears, preventing UI flicker. + // Agent sessions still use immediate clearing since they don't use the new DataApi path yet. + if (isAgentSessionTopicId(session.topicId)) { + // Agent Session → Clear immediately (legacy behavior, will be migrated later) + this.clearSession(messageId) + } else { + // Normal Topic → Emit event, let UI hook handle clearing after mutate() + EventEmitter.emit(EVENT_NAMES.STREAMING_FINALIZED, { + messageId, + topicId: session.topicId + }) + } logger.debug('Finalized streaming session', { messageId, status }) } catch (error) { logger.error('finalize failed:', error as Error)