feat: implement user message creation via Data API in StreamingService

- Added a new method `createUserMessage` in StreamingService to handle user message creation through the Data API, generating server-side message IDs while preserving client-generated block IDs.
- Updated `sendMessage` thunk to utilize the new method for normal topics, ensuring proper message handling based on the active agent session.
- Refactored block conversion logic to streamline the process of preparing message data for the API.
This commit is contained in:
fullex 2026-01-03 23:58:48 +08:00
parent e6f85ba9fc
commit b1de7283dc
2 changed files with 77 additions and 19 deletions

View File

@ -24,7 +24,7 @@ import { loggerService } from '@logger'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
import { isAgentSessionTopicId } from '@renderer/utils/agentSession'
import type { UpdateMessageDto } from '@shared/data/api/schemas/messages'
import type { CreateMessageDto, UpdateMessageDto } from '@shared/data/api/schemas/messages'
import type { MessageDataBlock, MessageStats } from '@shared/data/types/message'
import { dbService } from '../db'
@ -465,8 +465,64 @@ class StreamingService {
return nextGroupId
}
// ============ User Message Creation ============
/**
* Create a user message via Data API
*
* The message ID is generated by the server, not locally.
* Block IDs remain client-generated for Redux store use.
*
* TRADEOFF: Not passing parentId - Data API will use topic.activeNodeId as parent.
* In multi-window/multi-branch scenarios, this may cause incorrect associations
* if activeNodeId was changed by another window.
* TODO: In the future, parentId should come from the full message tree
* maintained in the topic UI, not from topic.activeNodeId.
*
* @param topicId - Topic ID
* @param message - Renderer format message (message.id will be ignored, server generates ID)
* @param blocks - Renderer format blocks (block IDs preserved for Redux)
* @returns Message with server-generated ID and original block IDs
*/
async createUserMessage(topicId: string, message: Message, blocks: MessageBlock[]): Promise<Message> {
// Convert blocks to MessageDataBlock format (remove id, status, messageId)
const dataBlocks = this.convertBlocksToDataFormat(blocks)
// Build CreateMessageDto (parentId omitted - API uses topic.activeNodeId)
const createDto: CreateMessageDto = {
role: 'user',
data: { blocks: dataBlocks },
status: 'success',
traceId: message.traceId ?? undefined
}
// POST to Data API - server generates message ID
const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto })
logger.debug('Created user message via Data API', { topicId, messageId: sharedMessage.id })
// Return message with server ID, preserving other fields from original message
return {
...message,
id: sharedMessage.id, // Use server-generated ID
blocks: blocks.map((b) => b.id) // Preserve client-generated block IDs
}
}
// ============ Internal Methods ============
/**
* Convert renderer MessageBlock[] to shared MessageDataBlock[]
* Removes renderer-specific fields: id, status, messageId
*/
private convertBlocksToDataFormat(blocks: MessageBlock[]): MessageDataBlock[] {
return blocks.map((block) => {
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
const { id, status, messageId, ...blockData } = block as MessageBlock & { messageId?: string }
return blockData as unknown as MessageDataBlock
})
}
/**
* Convert session data to database update payload
*
@ -530,16 +586,8 @@ class StreamingService {
// TRADEOFF: Using 'as unknown as' because renderer's MessageBlockType and shared's BlockType
// are structurally identical but TypeScript treats them as incompatible enums.
const dataBlocks: MessageDataBlock[] = blocks.map((block) => {
// Extract only the fields that belong to MessageDataBlock
const {
id: _id,
status: _blockStatus,
messageId: _messageId,
...blockData
} = block as MessageBlock & {
messageId?: string
}
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
const { id, status, messageId, ...blockData } = block as MessageBlock & { messageId?: string }
return blockData as unknown as MessageDataBlock
})

View File

@ -978,8 +978,18 @@ export const sendMessage =
userMessage.agentSessionId = activeAgentSession.agentSessionId
}
await saveMessageAndBlocksToDB(topicId, userMessage, userMessageBlocks)
dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
let finalUserMessage: Message
if (activeAgentSession) {
// Agent session: keep existing Dexie logic
await saveMessageAndBlocksToDB(topicId, userMessage, userMessageBlocks)
finalUserMessage = userMessage
} else {
// Normal topic: use Data API, get server-generated message ID
finalUserMessage = await streamingService.createUserMessage(topicId, userMessage, userMessageBlocks)
}
dispatch(newMessagesActions.addMessage({ topicId, message: finalUserMessage }))
if (userMessageBlocks.length > 0) {
dispatch(upsertManyBlocks(userMessageBlocks))
}
@ -989,7 +999,7 @@ export const sendMessage =
if (activeAgentSession) {
const assistantMessage = createAssistantMessage(assistant.id, topicId, {
askId: userMessage.id,
askId: finalUserMessage.id,
model: assistant.model,
traceId: userMessage.traceId
})
@ -1005,25 +1015,25 @@ export const sendMessage =
assistant,
assistantMessage,
agentSession: activeAgentSession,
userMessageId: userMessage.id
userMessageId: finalUserMessage.id
})
})
} else {
const mentionedModels = userMessage.mentions
const mentionedModels = finalUserMessage.mentions
if (mentionedModels && mentionedModels.length > 0) {
await dispatchMultiModelResponses(dispatch, getState, topicId, userMessage, assistant, mentionedModels)
await dispatchMultiModelResponses(dispatch, getState, topicId, finalUserMessage, assistant, mentionedModels)
} else {
// Create message via Data API for normal topics
const createDto: CreateMessageDto = {
parentId: userMessage.id,
parentId: finalUserMessage.id,
role: 'assistant',
data: { blocks: [] },
status: 'pending',
siblingsGroupId: 0,
assistantId: assistant.id,
modelId: assistant.model?.id,
traceId: userMessage.traceId ?? undefined
traceId: finalUserMessage.traceId ?? undefined
}
const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto })