feat(Messages): implement v2 migration for message handling and streaming support

- Introduced a new `blocks` prop in Message components to allow direct passing of MessageBlock objects, enhancing compatibility with DataApi and Streaming paths.
- Updated MessageContent to prioritize the new `blocks` prop over legacy message blocks.
- Modified MessageGroup to support a `blocksMap` for DataApi, facilitating direct access to block objects.
- Enhanced Messages component to utilize a unified hook for routing between DataApi and legacy paths, improving message loading and rendering logic.
- Implemented event-driven cache clearing for streaming sessions to prevent UI flicker during data refresh.
- Updated MessageBlockRenderer to handle both string IDs and MessageBlock objects, allowing for a phased migration from Redux.

This commit lays the groundwork for a smoother transition to the new architecture while maintaining backward compatibility.
This commit is contained in:
fullex 2026-01-05 21:06:33 +08:00
parent 096259cf27
commit 16596fd9bb
9 changed files with 743 additions and 23 deletions

View File

@ -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<void>
}
/**
* 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<void>
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 => (
* <StreamingMessageItem key={id} messageId={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<void>
/** 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
}
}

View File

@ -58,7 +58,11 @@ const AnimatedBlockWrapper: React.FC<AnimatedBlockWrapperProps> = ({ 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<Props> = ({ 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

View File

@ -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<Props> = ({
message,
blocks,
topic,
// assistant,
index,
@ -230,7 +232,7 @@ const MessageItem: FC<Props> = ({
overflowY: 'visible'
}}>
<MessageErrorBoundary>
<MessageContent message={message} />
<MessageContent message={message} blocks={blocks} />
</MessageErrorBoundary>
</MessageContentContainer>
{showMenubar && (

View File

@ -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<Props> = ({ message }) => {
const MessageContent: React.FC<Props> = ({ message, blocks }) => {
return (
<>
{!isEmpty(message.mentions) && (
@ -20,7 +21,10 @@ const MessageContent: React.FC<Props> = ({ message }) => {
))}
</Flex>
)}
<MessageBlockRenderer blocks={message.blocks} message={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). */}
<MessageBlockRenderer blocks={blocks ?? message.blocks} message={message} />
</>
)
}

View File

@ -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<string, MessageBlock[]> // 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<typeof MessageItem>
@ -258,6 +262,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
[
isGrid,
isGrouped,
blocksMap,
topic,
multiModelMessageStyle,
messages,

View File

@ -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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<MessagesProps> = ({ 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<string, MessageBlock[]> = {}
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<MessagesProps> = ({ 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 (
<MessagesContainer
@ -310,19 +405,24 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
onScroll={handleScrollPosition}>
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
<InfiniteScroll
dataLength={displayMessages.length}
next={loadMoreMessages}
hasMore={hasMore}
dataLength={infiniteScrollProps.dataLength}
next={infiniteScrollProps.next}
hasMore={infiniteScrollProps.hasMore}
loader={null}
scrollableTarget="messages"
inverse
style={{ overflow: 'visible' }}>
<ContextMenu>
<ScrollContainer>
{groupedMessages.map(([key, groupMessages]) => (
{/* NOTE: [v2 Migration] Render streaming messages first (at top) for DataApi path */}
{isDataApiPath &&
sessionIds.map((id) => <StreamingMessageItem key={`streaming-${id}`} messageId={id} topic={topic} />)}
{/* Render grouped messages (both DataApi and legacy paths) */}
{groupedMessages.map(({ key, messages: groupMessages, blocksMap }) => (
<MessageGroup
key={key}
messages={groupMessages}
blocksMap={blocksMap}
topic={topic}
registerMessageElement={registerMessageElement}
/>
@ -338,6 +438,9 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
{showPrompt && <Prompt assistant={assistant} key={assistant.prompt} topic={topic} />}
</NarrowLayout>
{/* 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' && <MessageAnchorLine messages={displayMessages} />}
<SelectionBox
isMultiSelectMode={isMultiSelectMode}

View File

@ -0,0 +1,155 @@
/**
* @fileoverview StreamingMessageItem - Renders a single streaming message
*
* This component subscribes to its own streaming session cache and re-renders
* only when that specific session updates. This is more efficient than having
* a parent component subscribe to all sessions.
*
* ## Architecture
*
* Each streaming message renders independently:
* ```
*
* Messages.tsx
* useStreamingSessionIds(topicId) ['msg1', 'msg2']
* StreamingMessageItem key='msg1'
* useCache('message.streaming.session.msg1') subscribes here
* StreamingMessageItem key='msg2'
* useCache('message.streaming.session.msg2') subscribes here
*
* ```
*
* ## Why Not Group in Parent?
*
* TRADEOFF: Independent components vs parent-level grouping
*
* Parent-level grouping (rejected):
* - Requires calling getSession() for each ID in render
* - getSession() returns point-in-time snapshot, not reactive
* - Parent doesn't re-render on session updates (stale data)
*
* Independent components (chosen):
* - Each component subscribes via useCache
* - Only affected component re-renders on update
* - Multi-model grouping handled by component styling, not data structure
*
* ## Multi-Model Responses
*
* For multi-model responses (siblingsGroupId > 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<string, MessageBlock>
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 => (
* <StreamingMessageItem
* key={id}
* messageId={id}
* topic={topic}
* />
* ))}
* </>
* )
* ```
*/
const StreamingMessageItem: React.FC<Props> = ({ 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<id, block>
// MessageItem expects either string[] (Redux) or MessageBlock[] (direct)
// We pass the array directly (new path) to bypass Redux
const blockArray = Object.values(blocks)
return (
<MessageEditingProvider>
<MessageItem
message={message}
blocks={blockArray}
topic={topic}
index={index}
isStreaming={true}
// Multi-model responses have siblingsGroupId > 0
// isGrouped styling is handled by MessageItem based on this
isGrouped={sessionData.siblingsGroupId > 0}
/>
</MessageEditingProvider>
)
}
// Memoize to prevent unnecessary re-renders from parent
// The component will re-render when its cache subscription updates
export default memo(StreamingMessageItem)

View File

@ -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'
}

View File

@ -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)