From b1de7283dc7947677b503d6e47d70104e049cf30 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 3 Jan 2026 23:58:48 +0800 Subject: [PATCH] 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. --- .../messageStreaming/StreamingService.ts | 70 ++++++++++++++++--- src/renderer/src/store/thunk/messageThunk.ts | 26 ++++--- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts index 29e0a484cd..7e3a7bd28f 100644 --- a/src/renderer/src/services/messageStreaming/StreamingService.ts +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -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 { + // 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 }) diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 7bafb27cdb..de8f9e26d3 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -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 })