diff --git a/packages/mukti-api/src/modules/auth/auth.controller.ts b/packages/mukti-api/src/modules/auth/auth.controller.ts index fba95421..f21bb9fc 100644 --- a/packages/mukti-api/src/modules/auth/auth.controller.ts +++ b/packages/mukti-api/src/modules/auth/auth.controller.ts @@ -23,6 +23,10 @@ import { ConfigService } from '@nestjs/config'; import { AuthGuard } from '@nestjs/passport'; import { ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { Public } from '../../common/decorators/public.decorator'; +import { SkipEnvelope } from '../../common/decorators/skip-envelope.decorator'; +import { WaitlistService } from '../waitlist/waitlist.service'; import { AuthResponseDto, ChangePasswordDto, @@ -34,11 +38,6 @@ import { UserResponseDto, VerifyEmailDto, } from './dto'; - -import { CurrentUser } from '../../common/decorators/current-user.decorator'; -import { Public } from '../../common/decorators/public.decorator'; -import { SkipEnvelope } from '../../common/decorators/skip-envelope.decorator'; -import { WaitlistService } from '../waitlist/waitlist.service'; import { ApiChangePassword, ApiForgotPassword, diff --git a/packages/mukti-api/src/modules/dialogue/services/dialogue-ai.service.ts b/packages/mukti-api/src/modules/dialogue/services/dialogue-ai.service.ts index 433e167c..43546c3b 100644 --- a/packages/mukti-api/src/modules/dialogue/services/dialogue-ai.service.ts +++ b/packages/mukti-api/src/modules/dialogue/services/dialogue-ai.service.ts @@ -391,6 +391,8 @@ export class DialogueAIService { /** * Builds the messages array for the OpenRouter API. + * Skips the user message when empty (e.g. initial question generation + * where the system prompt already contains all instructions). */ private buildMessages( systemPrompt: string, @@ -416,11 +418,13 @@ export class DialogueAIService { }); } - // Add current user message - messages.push({ - content: userMessage, - role: 'user', - }); + // Add current user message (skip when empty — e.g. initial question generation) + if (userMessage) { + messages.push({ + content: userMessage, + role: 'user', + }); + } return messages; } diff --git a/packages/mukti-api/src/modules/dialogue/utils/prompt-builder.ts b/packages/mukti-api/src/modules/dialogue/utils/prompt-builder.ts index e3973087..488e4b49 100644 --- a/packages/mukti-api/src/modules/dialogue/utils/prompt-builder.ts +++ b/packages/mukti-api/src/modules/dialogue/utils/prompt-builder.ts @@ -164,6 +164,51 @@ Guidelines: - Keep responses concise (2-4 sentences typically)`; } +/** + * Builds a system prompt for generating the AI-powered initial Socratic question + * for a ThoughtMap node dialogue. + * + * Extends the base ThoughtMap system prompt with: + * - Sibling node context (labels of nodes sharing the same parent) + * - Explicit instruction to produce a single opening question + * + * @param node - The node context being discussed + * @param mapTitle - The Thought Map's root topic/title + * @param technique - The Socratic technique to apply + * @param siblingLabels - Labels of sibling nodes (same parent) for branch context + * @returns The constructed system prompt for initial question generation + */ +export function buildThoughtMapInitialQuestionPrompt( + node: NodeContext, + mapTitle: string, + technique: SocraticTechnique, + siblingLabels: string[], +): string { + const basePrompt = buildThoughtMapSystemPrompt(node, mapTitle, technique); + + const siblingContext = + siblingLabels.length > 0 + ? `\nOther nodes at this level: ${siblingLabels.map((l) => `"${l}"`).join(', ')}\n` + : ''; + + return `${basePrompt} +${siblingContext} +--- +TASK: Generate an opening Socratic question for this node. +--- + +This is the very first message in the dialogue — there is no prior conversation. + +Your question should: +- Be tailored to the node's type (${node.nodeType}) and its position in the thinking map +- Consider the broader topic context ("${mapTitle}") +- Invite the user to begin exploring this node's subject matter +- Be warm and engaging while intellectually challenging +- Follow the ${technique} technique guidelines above + +Respond with ONLY the opening question — no preamble, no meta-commentary.`; +} + /** * Builds the system prompt for Thought Map node dialogue. * Substitutes map context (title + node summary) instead of canvas problem structure. @@ -248,6 +293,9 @@ export function generateInitialQuestion( * @param nodeType - The ThoughtMap node type * @param nodeLabel - The node's content/label * @returns An initial question tailored to the node type + * + * @deprecated Use `buildThoughtMapInitialQuestionPrompt` + AI generation instead. + * Kept as a fallback for when the AI service is unavailable. */ export function generateThoughtMapInitialQuestion( nodeType: NodeType, diff --git a/packages/mukti-api/src/modules/thought-map/__tests__/thought-map-dialogue.controller.spec.ts b/packages/mukti-api/src/modules/thought-map/__tests__/thought-map-dialogue.controller.spec.ts index 54d26f5f..d821e4ef 100644 --- a/packages/mukti-api/src/modules/thought-map/__tests__/thought-map-dialogue.controller.spec.ts +++ b/packages/mukti-api/src/modules/thought-map/__tests__/thought-map-dialogue.controller.spec.ts @@ -51,6 +51,7 @@ describe('ThoughtMapDialogueController', () => { }; const mockAiPolicyService = { + getDefaultModel: jest.fn().mockReturnValue('openai/gpt-5-mini'), resolveEffectiveModel: jest.fn().mockResolvedValue('resolved-model'), }; @@ -68,6 +69,7 @@ describe('ThoughtMapDialogueController', () => { const mockThoughtNodeModel = { countDocuments: jest.fn(), findOne: jest.fn(), + updateOne: jest.fn().mockResolvedValue({}), }; beforeEach(async () => { @@ -153,6 +155,8 @@ describe('ThoughtMapDialogueController', () => { mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId }); mockThoughtNodeModel.findOne.mockResolvedValue({ + depth: 1, + fromSuggestion: false, label: 'Node label', type: 'thought', }); @@ -171,10 +175,13 @@ describe('ThoughtMapDialogueController', () => { ); expect(mockDialogueService.addMessage).not.toHaveBeenCalled(); - expect(result.initialQuestion.content).toBe('Existing question'); + expect(result).toHaveProperty( + 'initialQuestion.content', + 'Existing question', + ); }); - it('creates the initial question when the dialogue is empty', async () => { + it('enqueues an AI job using server key when dialogue is empty and user has no BYOK', async () => { const mapId = new Types.ObjectId().toString(); const dialogue = { _id: new Types.ObjectId(), @@ -184,49 +191,199 @@ describe('ThoughtMapDialogueController', () => { nodeLabel: 'Node label', nodeType: 'thought', }; - const updatedDialogue = { - ...dialogue, - messageCount: 1, - }; - const createdMessage = { + + mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId }); + mockThoughtNodeModel.findOne.mockResolvedValue({ + depth: 1, + fromSuggestion: false, + label: 'Node label', + type: 'thought', + }); + mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue( + dialogue, + ); + mockDialogueService.getMessages.mockResolvedValue({ + messages: [], + pagination: { total: 0 }, + }); + setUserRecord({ + openRouterApiKeyEncrypted: undefined, + preferences: { activeModel: 'saved-model' }, + }); + mockThoughtMapDialogueQueueService.enqueueMapNodeRequest.mockResolvedValue({ + jobId: 'job-1', + position: 1, + }); + + const result = await controller.startDialogue( + mapId, + 'thought-0', + mockUser as any, + ); + + // Should NOT create a message synchronously — the queue worker does that + expect(mockDialogueService.addMessage).not.toHaveBeenCalled(); + + // Should resolve model via policy service (no requestedModel for initial question) + expect(mockAiPolicyService.resolveEffectiveModel).toHaveBeenCalledWith({ + hasByok: false, + userActiveModel: 'saved-model', + validationApiKey: 'server-openrouter-key', + }); + + // Should enqueue a job with isInitialQuestion flag and resolved model + expect( + mockThoughtMapDialogueQueueService.enqueueMapNodeRequest, + ).toHaveBeenCalledWith( + mockUser._id, + mapId, + 'thought-0', + 'thought', + 'Node label', + 1, // depth + false, // fromSuggestion + 0, // siblings (no parentId) + undefined, // parentType + '', // empty message + 'free', // subscriptionTier + 'resolved-model', // effectiveModel from resolveEffectiveModel + false, // not BYOK + true, // isInitialQuestion + ); + + // Should return async response shape with jobId + expect('jobId' in result).toBe(true); + if ('jobId' in result) { + expect(result.jobId).toBe('job-1'); + expect(result.position).toBe(1); + } + }); + + it('enqueues an AI job using BYOK when dialogue is empty and user has an API key', async () => { + const mapId = new Types.ObjectId().toString(); + const dialogue = { _id: new Types.ObjectId(), - content: - 'You\'ve noted: "Node label". What led you to this thought? Is this an observation, an assumption, or a conclusion?', createdAt: new Date('2026-01-01T00:00:00.000Z'), - dialogueId: dialogue._id, - metadata: { model: 'system' }, - role: 'assistant', - sequence: 0, + messageCount: 0, + nodeId: 'thought-0', + nodeLabel: 'Node label', + nodeType: 'thought', }; mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId }); mockThoughtNodeModel.findOne.mockResolvedValue({ + depth: 1, + fromSuggestion: false, label: 'Node label', type: 'thought', }); - mockThoughtMapDialogueQueueService.getOrCreateMapDialogue - .mockResolvedValueOnce(dialogue) - .mockResolvedValueOnce(updatedDialogue); + mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue( + dialogue, + ); mockDialogueService.getMessages.mockResolvedValue({ messages: [], pagination: { total: 0 }, }); - mockDialogueService.addMessage.mockResolvedValue(createdMessage); + setUserRecord({ + openRouterApiKeyEncrypted: 'encrypted-key', + preferences: { activeModel: 'saved-model' }, + }); + mockThoughtMapDialogueQueueService.enqueueMapNodeRequest.mockResolvedValue({ + jobId: 'job-2', + position: 1, + }); - const result = await controller.startDialogue( + await controller.startDialogue(mapId, 'thought-0', mockUser as any); + + expect(mockAiSecretsService.decryptString).toHaveBeenCalledWith( + 'encrypted-key', + ); + expect(mockAiPolicyService.resolveEffectiveModel).toHaveBeenCalledWith({ + hasByok: true, + userActiveModel: 'saved-model', + validationApiKey: 'decrypted-key', + }); + expect( + mockThoughtMapDialogueQueueService.enqueueMapNodeRequest, + ).toHaveBeenCalledWith( + expect.anything(), mapId, 'thought-0', - mockUser as any, + 'thought', + 'Node label', + 1, + false, + 0, + undefined, + '', + 'free', + 'resolved-model', + true, // BYOK + true, // isInitialQuestion + ); + }); + + it('throws when server key is missing and user has no BYOK on startDialogue', async () => { + const mapId = new Types.ObjectId().toString(); + const dialogue = { + _id: new Types.ObjectId(), + messageCount: 0, + nodeId: 'thought-0', + nodeLabel: 'Node label', + nodeType: 'thought', + }; + + mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId }); + mockThoughtNodeModel.findOne.mockResolvedValue({ + depth: 0, + fromSuggestion: false, + label: 'Node label', + type: 'thought', + }); + mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue( + dialogue, ); + mockDialogueService.getMessages.mockResolvedValue({ + messages: [], + pagination: { total: 0 }, + }); + setUserRecord({ openRouterApiKeyEncrypted: undefined, preferences: {} }); + mockConfigService.get.mockReturnValueOnce(''); - expect(mockDialogueService.addMessage).toHaveBeenCalledWith( - dialogue._id, - 'assistant', - createdMessage.content, - { model: 'system' }, + await expect( + controller.startDialogue(mapId, 'thought-0', mockUser as any), + ).rejects.toThrow('OPENROUTER_API_KEY not configured'); + }); + + it('throws when the user record is missing during startDialogue', async () => { + const mapId = new Types.ObjectId().toString(); + const dialogue = { + _id: new Types.ObjectId(), + messageCount: 0, + nodeId: 'thought-0', + nodeLabel: 'Node label', + nodeType: 'thought', + }; + + mockThoughtMapService.findMapById.mockResolvedValue({ _id: mapId }); + mockThoughtNodeModel.findOne.mockResolvedValue({ + depth: 0, + fromSuggestion: false, + label: 'Node label', + type: 'thought', + }); + mockThoughtMapDialogueQueueService.getOrCreateMapDialogue.mockResolvedValue( + dialogue, ); - expect(result.dialogue.messageCount).toBe(1); - expect(result.initialQuestion.content).toBe(createdMessage.content); + mockDialogueService.getMessages.mockResolvedValue({ + messages: [], + pagination: { total: 0 }, + }); + mockUserModel.lean.mockResolvedValue(null); + + await expect( + controller.startDialogue(mapId, 'thought-0', mockUser as any), + ).rejects.toThrow('User not found'); }); it('returns empty pagination when no dialogue exists yet', async () => { diff --git a/packages/mukti-api/src/modules/thought-map/__tests__/thought-map.dto.spec.ts b/packages/mukti-api/src/modules/thought-map/__tests__/thought-map.dto.spec.ts index 85089092..b916abfe 100644 --- a/packages/mukti-api/src/modules/thought-map/__tests__/thought-map.dto.spec.ts +++ b/packages/mukti-api/src/modules/thought-map/__tests__/thought-map.dto.spec.ts @@ -132,8 +132,8 @@ describe('ThoughtMap DTO validation', () => { ).not.toHaveLength(0); }); - it('surfaces the current ConvertCanvasDto optional-title contract mismatch', () => { - expect(validate(ConvertCanvasDto, {})).not.toHaveLength(0); + it('accepts ConvertCanvasDto when title is omitted', () => { + expect(validate(ConvertCanvasDto, {})).toHaveLength(0); }); it('accepts a non-empty ConvertCanvasDto title when provided', () => { diff --git a/packages/mukti-api/src/modules/thought-map/dto/convert-canvas.dto.ts b/packages/mukti-api/src/modules/thought-map/dto/convert-canvas.dto.ts index ba157855..d47e1ded 100644 --- a/packages/mukti-api/src/modules/thought-map/dto/convert-canvas.dto.ts +++ b/packages/mukti-api/src/modules/thought-map/dto/convert-canvas.dto.ts @@ -21,8 +21,8 @@ export class ConvertCanvasDto { maxLength: 500, required: false, }) - @IsOptional() @IsNotEmpty() + @IsOptional() @IsString() title?: string; } diff --git a/packages/mukti-api/src/modules/thought-map/dto/thought-map-dialogue.swagger.ts b/packages/mukti-api/src/modules/thought-map/dto/thought-map-dialogue.swagger.ts index 66cfcac4..15852ff8 100644 --- a/packages/mukti-api/src/modules/thought-map/dto/thought-map-dialogue.swagger.ts +++ b/packages/mukti-api/src/modules/thought-map/dto/thought-map-dialogue.swagger.ts @@ -17,7 +17,7 @@ export const ApiStartThoughtMapNodeDialogue = () => applyDecorators( ApiOperation({ description: - 'Starts a Socratic dialogue for a Thought Map node by generating an initial question. Creates the dialogue if it does not exist. Technique is auto-selected based on node context (RFC §5.1.1).', + 'Starts a Socratic dialogue for a Thought Map node. If the dialogue does not exist, creates it and enqueues AI generation of the initial question (async path: returns jobId + position, subscribe to SSE for the question). If the dialogue already exists, returns the first message directly (sync path: returns initialQuestion). Technique is auto-selected based on node context (RFC §5.1.1).', summary: 'Start Thought Map node dialogue', }), ApiBearerAuth(), @@ -32,7 +32,33 @@ export const ApiStartThoughtMapNodeDialogue = () => name: 'nodeId', }), ApiResponse({ - description: 'Dialogue started with initial Socratic question', + description: + 'Async path — new dialogue created, AI generating initial question via queue. Subscribe to the SSE stream for the initial question.', + schema: { + example: { + data: { + dialogue: { + createdAt: '2026-01-01T00:00:00Z', + id: '507f1f77bcf86cd799439012', + lastMessageAt: null, + messageCount: 0, + nodeId: 'thought-0', + nodeLabel: 'The process is unclear', + nodeType: 'thought', + sessionId: '', + }, + jobId: 'job-123', + position: 1, + }, + meta: { requestId: 'uuid', timestamp: '2026-01-01T00:00:00Z' }, + success: true, + }, + }, + status: 202, + }), + ApiResponse({ + description: + 'Sync path — dialogue already exists, returns the first message directly.', schema: { example: { data: { @@ -61,7 +87,7 @@ export const ApiStartThoughtMapNodeDialogue = () => success: true, }, }, - status: 201, + status: 202, }), ApiResponse({ description: 'Unauthorized', status: 401 }), ApiResponse({ diff --git a/packages/mukti-api/src/modules/thought-map/services/__tests__/thought-map-dialogue-queue.service.spec.ts b/packages/mukti-api/src/modules/thought-map/services/__tests__/thought-map-dialogue-queue.service.spec.ts index cf797927..803f3e4d 100644 --- a/packages/mukti-api/src/modules/thought-map/services/__tests__/thought-map-dialogue-queue.service.spec.ts +++ b/packages/mukti-api/src/modules/thought-map/services/__tests__/thought-map-dialogue-queue.service.spec.ts @@ -546,6 +546,380 @@ describe('ThoughtMapDialogueQueueService', () => { }); }); + describe('processInitialQuestion (via process with isInitialQuestion=true)', () => { + const dialogue = { + _id: new Types.ObjectId(), + consecutiveFailures: 0, + consecutiveSuccesses: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + currentScaffoldLevel: ScaffoldLevel.PURE_SOCRATIC, + detectedConcepts: [], + lastMessageAt: null, + messageCount: 0, + nodeId: 'thought-0', + nodeLabel: 'My thought', + nodeType: 'thought', + }; + + const aiResponse = { + completionTokens: 20, + content: 'What assumptions are you making about this idea?', + cost: 0.01, + latencyMs: 120, + model: 'allowed-model', + promptTokens: 50, + totalTokens: 70, + }; + + const aiMessage = { + _id: new Types.ObjectId(), + content: aiResponse.content, + createdAt: new Date('2026-01-01T00:00:03.000Z'), + sequence: 0, + }; + + const makeInitialQuestionJob = (mapId: string, overrides = {}) => + ({ + data: { + depth: 1, + fromSuggestion: false, + isInitialQuestion: true, + mapId, + message: '', + model: 'allowed-model', + nodeId: 'thought-0', + nodeLabel: 'My thought', + nodeType: 'thought' as const, + parentType: 'question' as const, + siblings: 2, + subscriptionTier: 'free', + usedByok: false, + userId: new Types.ObjectId().toString(), + ...overrides, + }, + id: 'init-job-1', + }) as any; + + it('emits processing, progress, message, and complete SSE events in order', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest + .spyOn(service as any, 'resolveSiblingLabels') + .mockResolvedValue(['Sibling A', 'Sibling B']); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + await service.process(makeInitialQuestionJob(mapId)); + + const emitCalls = mockDialogueStreamService.emitToNodeDialogue.mock.calls; + const eventTypes = emitCalls.map(([, , , event]) => event.type); + + expect(eventTypes).toEqual([ + 'processing', + 'progress', + 'message', + 'complete', + ]); + + // Verify processing event payload + expect(emitCalls[0][3]).toEqual({ + data: { jobId: 'init-job-1', status: 'started' }, + type: 'processing', + }); + + // Verify progress event payload + expect(emitCalls[1][3]).toEqual({ + data: { jobId: 'init-job-1', status: 'Generating opening question...' }, + type: 'progress', + }); + + // Verify message event payload + expect(emitCalls[2][3]).toEqual({ + data: { + content: aiResponse.content, + role: 'assistant', + sequence: aiMessage.sequence, + timestamp: aiMessage.createdAt.toISOString(), + tokens: aiResponse.totalTokens, + }, + type: 'message', + }); + + // Verify complete event payload + expect(emitCalls[3][3]).toEqual({ + data: { + cost: aiResponse.cost, + jobId: 'init-job-1', + latency: expect.any(Number), + tokens: aiResponse.totalTokens, + }, + type: 'complete', + }); + + // All SSE events use the map-scoped stream key + for (const call of emitCalls) { + expect(call[0]).toBe(`map:${mapId}`); + expect(call[1]).toBe('thought-0'); + expect(call[2]).toBe(dialogue._id.toString()); + } + }); + + it('calls buildThoughtMapInitialQuestionPrompt with correct node context and sibling labels', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Deep thinking'); + jest + .spyOn(service as any, 'resolveSiblingLabels') + .mockResolvedValue(['Branch A']); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + // We spy on the imported function via the module + const promptBuilder = jest.requireActual( + '../../../dialogue/utils/prompt-builder', + ); + const buildSpy = jest.spyOn( + promptBuilder, + 'buildThoughtMapInitialQuestionPrompt', + ); + + // Since the service imports the function directly, we need to verify through + // the arguments passed to generateScaffoldedResponseWithPrompt instead + await service.process(makeInitialQuestionJob(mapId)); + + // The system prompt passed to generateScaffoldedResponseWithPrompt should be + // the output of buildThoughtMapInitialQuestionPrompt + const genCall = + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mock + .calls[0]; + const systemPrompt = genCall[0]; + + // Verify the prompt contains node context and sibling labels + // (buildThoughtMapInitialQuestionPrompt includes the node label, map title, and siblings) + expect(typeof systemPrompt).toBe('string'); + expect(systemPrompt.length).toBeGreaterThan(0); + + buildSpy.mockRestore(); + }); + + it('calls generateScaffoldedResponseWithPrompt with empty history and empty user message', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest.spyOn(service as any, 'resolveSiblingLabels').mockResolvedValue([]); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + await service.process(makeInitialQuestionJob(mapId)); + + expect( + mockDialogueAIService.generateScaffoldedResponseWithPrompt, + ).toHaveBeenCalledWith( + expect.any(String), // system prompt + [], // empty conversation history + '', // empty user message + 'allowed-model', // effective model + 'server-openrouter-key', // resolved API key + ); + }); + + it('persists the assistant message via dialogueService.addMessage', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest.spyOn(service as any, 'resolveSiblingLabels').mockResolvedValue([]); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + await service.process(makeInitialQuestionJob(mapId)); + + expect(mockDialogueService.addMessage).toHaveBeenCalledWith( + dialogue._id, + 'assistant', + aiResponse.content, + { + latencyMs: aiResponse.latencyMs, + model: aiResponse.model, + tokens: aiResponse.totalTokens, + }, + ); + // Only assistant message — no user message should be added + expect(mockDialogueService.addMessage).toHaveBeenCalledTimes(1); + }); + + it('creates a UsageEvent with eventType THOUGHT_MAP_INITIAL_QUESTION', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest.spyOn(service as any, 'resolveSiblingLabels').mockResolvedValue([]); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + await service.process(makeInitialQuestionJob(mapId)); + + expect(mockUsageEventModel.create).toHaveBeenCalledWith({ + eventType: 'THOUGHT_MAP_INITIAL_QUESTION', + metadata: { + completionTokens: aiResponse.completionTokens, + cost: aiResponse.cost, + dialogueId: dialogue._id, + latencyMs: aiResponse.latencyMs, + mapId: expect.any(Types.ObjectId), + model: aiResponse.model, + nodeId: 'thought-0', + nodeType: 'thought', + promptTokens: aiResponse.promptTokens, + tokens: aiResponse.totalTokens, + }, + timestamp: expect.any(Date), + userId: expect.any(Types.ObjectId), + }); + }); + + it('returns a result with userMessageId set to empty string', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest.spyOn(service as any, 'resolveSiblingLabels').mockResolvedValue([]); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + const result = await service.process(makeInitialQuestionJob(mapId)); + + expect(result).toEqual({ + assistantMessageId: aiMessage._id.toString(), + cost: aiResponse.cost, + dialogueId: dialogue._id.toString(), + latency: expect.any(Number), + tokens: aiResponse.totalTokens, + userMessageId: '', + }); + }); + + it('does not save a user message or mark the node as explored', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest.spyOn(service as any, 'resolveSiblingLabels').mockResolvedValue([]); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + await service.process(makeInitialQuestionJob(mapId)); + + // addMessage should only be called once (for the assistant), not for a user message + expect(mockDialogueService.addMessage).toHaveBeenCalledTimes(1); + expect(mockDialogueService.addMessage).toHaveBeenCalledWith( + dialogue._id, + 'assistant', + expect.any(String), + expect.any(Object), + ); + // Node should NOT be marked as explored for initial questions + expect(mockThoughtNodeModel.updateOne).not.toHaveBeenCalled(); + }); + + it('emits error SSE event and re-throws when AI generation fails', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest.spyOn(service as any, 'resolveSiblingLabels').mockResolvedValue([]); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockRejectedValue( + new Error('Model overloaded'), + ); + + await expect( + service.process(makeInitialQuestionJob(mapId)), + ).rejects.toThrow('Model overloaded'); + + const lastEmit = + mockDialogueStreamService.emitToNodeDialogue.mock.calls.at(-1)?.[3]; + expect(lastEmit).toEqual({ + data: { + code: 'PROCESSING_ERROR', + message: 'Model overloaded', + retriable: true, + }, + type: 'error', + }); + }); + + it('uses selectTechniqueForNode with correct technique context', async () => { + const mapId = validMapId(); + jest + .spyOn(service as any, 'getOrCreateMapDialogue') + .mockResolvedValue(dialogue); + jest + .spyOn(service as any, 'resolveMapTitle') + .mockResolvedValue('Map title'); + jest.spyOn(service as any, 'resolveSiblingLabels').mockResolvedValue([]); + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mockResolvedValue( + aiResponse, + ); + mockDialogueService.addMessage.mockResolvedValue(aiMessage); + + // Use depth=0 to verify selectTechniqueForNode picks 'maieutics' + await service.process( + makeInitialQuestionJob(mapId, { depth: 0, parentType: undefined }), + ); + + // For depth=0, selectTechniqueForNode returns 'maieutics' + // The system prompt (first arg to generateScaffoldedResponseWithPrompt) + // should reflect this technique + const genCall = + mockDialogueAIService.generateScaffoldedResponseWithPrompt.mock + .calls[0]; + const systemPrompt: string = genCall[0]; + expect(systemPrompt).toContain('maieutics'); + }); + }); + describe('private helpers', () => { it('validates curated models when BYOK is not used', () => { expect(() => diff --git a/packages/mukti-api/src/modules/thought-map/services/thought-map-dialogue-queue.service.ts b/packages/mukti-api/src/modules/thought-map/services/thought-map-dialogue-queue.service.ts index 063ea029..b6174ca6 100644 --- a/packages/mukti-api/src/modules/thought-map/services/thought-map-dialogue-queue.service.ts +++ b/packages/mukti-api/src/modules/thought-map/services/thought-map-dialogue-queue.service.ts @@ -30,6 +30,7 @@ import { DialogueAIService } from '../../dialogue/services/dialogue-ai.service'; import { DialogueStreamService } from '../../dialogue/services/dialogue-stream.service'; import { DialogueService } from '../../dialogue/services/dialogue.service'; import { + buildThoughtMapInitialQuestionPrompt, buildThoughtMapSystemPrompt, selectTechniqueForNode, type ThoughtMapNodeTechniqueContext, @@ -51,6 +52,8 @@ export interface ThoughtMapDialogueJobData { depth: number; /** Whether the node was created from an AI suggestion */ fromSuggestion: boolean; + /** Whether this is a system-initiated initial question (no user message) */ + isInitialQuestion?: boolean; mapId: string; message: string; model: string; @@ -159,6 +162,7 @@ export class ThoughtMapDialogueQueueService extends WorkerHost { subscriptionTier: string, model: string, usedByok: boolean, + isInitialQuestion?: boolean, ): Promise<{ jobId: string; position: number }> { const userIdString = userId.toString(); this.logger.log( @@ -170,6 +174,7 @@ export class ThoughtMapDialogueQueueService extends WorkerHost { const jobData: ThoughtMapDialogueJobData = { depth, fromSuggestion, + isInitialQuestion, mapId, message, model, @@ -275,6 +280,10 @@ export class ThoughtMapDialogueQueueService extends WorkerHost { async process( job: Job, ): Promise { + if (job.data.isInitialQuestion) { + return this.processInitialQuestion(job); + } + const startTime = Date.now(); const { depth, @@ -602,6 +611,205 @@ export class ThoughtMapDialogueQueueService extends WorkerHost { return error instanceof Error ? error.stack : undefined; } + /** + * Processes an initial-question job: generates the AI-powered opening Socratic + * question for a node that has no prior dialogue history. + * + * Unlike a regular dialogue turn, this: + * - Does NOT save a user message (there is none) + * - Builds a prompt specifically for initial question generation + * - Includes sibling node labels for branch context + */ + private async processInitialQuestion( + job: Job, + ): Promise { + const startTime = Date.now(); + const { + depth, + fromSuggestion, + mapId, + model, + nodeId, + nodeLabel, + nodeType, + parentType, + siblings, + usedByok, + userId, + } = job.data; + + this.logger.log( + `Processing initial question job ${job.id}: user=${userId}, map=${mapId}, node=${nodeId}`, + ); + + const streamKey = mapStreamKey(mapId); + const dialogue = await this.getOrCreateMapDialogue( + mapId, + nodeId, + nodeType, + nodeLabel, + ); + const dialogueId = dialogue._id.toString(); + + try { + // Emit processing started + this.dialogueStreamService.emitToNodeDialogue( + streamKey, + nodeId, + dialogueId, + { + data: { jobId: job.id!, status: 'started' }, + type: 'processing', + }, + ); + + this.dialogueStreamService.emitToNodeDialogue( + streamKey, + nodeId, + dialogueId, + { + data: { jobId: job.id!, status: 'Generating opening question...' }, + type: 'progress', + }, + ); + + const effectiveModel = this.validateEffectiveModel(model, usedByok); + const apiKey = await this.resolveApiKey(userId, usedByok); + + // Select technique via RFC §5.1.1 algorithm + const techniqueCtx: ThoughtMapNodeTechniqueContext = { + depth, + fromSuggestion, + parentType, + siblings, + type: nodeType, + }; + const technique = selectTechniqueForNode(techniqueCtx); + + // Resolve map title and sibling labels for prompt context + const [mapTitle, siblingLabels] = await Promise.all([ + this.resolveMapTitle(mapId, nodeId), + this.resolveSiblingLabels(mapId, nodeId), + ]); + + // Build the initial question prompt with branch context + const systemPrompt = buildThoughtMapInitialQuestionPrompt( + { nodeId, nodeLabel, nodeType }, + mapTitle, + technique, + siblingLabels, + ); + + // Generate AI response — no conversation history, no user message + const aiResponse = + await this.dialogueAIService.generateScaffoldedResponseWithPrompt( + systemPrompt, + [], // No conversation history + '', // No user message + effectiveModel, + apiKey, + ); + + const aiMessage = await this.dialogueService.addMessage( + dialogue._id, + 'assistant', + aiResponse.content, + { + latencyMs: aiResponse.latencyMs, + model: aiResponse.model, + tokens: aiResponse.totalTokens, + }, + ); + + // Emit the AI-generated initial question via SSE + this.dialogueStreamService.emitToNodeDialogue( + streamKey, + nodeId, + dialogueId, + { + data: { + content: aiResponse.content, + role: 'assistant', + sequence: aiMessage.sequence, + timestamp: aiMessage.createdAt.toISOString(), + tokens: aiResponse.totalTokens, + }, + type: 'message', + }, + ); + + // Log usage event + await this.usageEventModel.create({ + eventType: 'THOUGHT_MAP_INITIAL_QUESTION', + metadata: { + completionTokens: aiResponse.completionTokens, + cost: aiResponse.cost, + dialogueId: dialogue._id, + latencyMs: aiResponse.latencyMs, + mapId: new Types.ObjectId(mapId), + model: aiResponse.model, + nodeId, + nodeType, + promptTokens: aiResponse.promptTokens, + tokens: aiResponse.totalTokens, + }, + timestamp: new Date(), + userId: new Types.ObjectId(userId), + }); + + const latency = Date.now() - startTime; + + this.dialogueStreamService.emitToNodeDialogue( + streamKey, + nodeId, + dialogueId, + { + data: { + cost: aiResponse.cost, + jobId: job.id!, + latency, + tokens: aiResponse.totalTokens, + }, + type: 'complete', + }, + ); + + this.logger.log( + `Initial question job ${job.id} completed in ${latency}ms`, + ); + + return { + assistantMessageId: aiMessage._id.toString(), + cost: aiResponse.cost, + dialogueId, + latency, + tokens: aiResponse.totalTokens, + userMessageId: '', + }; + } catch (error) { + this.logger.error( + `Initial question job ${job.id} failed: ${this.getErrorMessage(error)}`, + this.getErrorStack(error), + ); + + this.dialogueStreamService.emitToNodeDialogue( + streamKey, + nodeId, + dialogueId, + { + data: { + code: 'PROCESSING_ERROR', + message: this.getErrorMessage(error), + retriable: true, + }, + type: 'error', + }, + ); + + throw error; + } + } + private async resolveApiKey( userId: string, usedByok: boolean, @@ -641,6 +849,35 @@ export class ThoughtMapDialogueQueueService extends WorkerHost { return rootNode?.label ?? 'Unknown topic'; } + /** + * Resolves the labels of sibling nodes (same parent) for branch context. + * Excludes the current node from the results. + */ + private async resolveSiblingLabels( + mapId: string, + nodeId: string, + ): Promise { + const mapObjectId = new Types.ObjectId(mapId); + const currentNode = await this.thoughtNodeModel + .findOne({ mapId: mapObjectId, nodeId }) + .lean(); + + if (!currentNode?.parentId) { + return []; + } + + const siblings = await this.thoughtNodeModel + .find({ + mapId: mapObjectId, + nodeId: { $ne: nodeId }, + parentId: currentNode.parentId, + }) + .select('label') + .lean(); + + return siblings.map((s) => s.label); + } + private validateEffectiveModel(model: string, usedByok: boolean): string { const trimmed = model.trim(); if (!trimmed) { diff --git a/packages/mukti-api/src/modules/thought-map/thought-map-dialogue.controller.ts b/packages/mukti-api/src/modules/thought-map/thought-map-dialogue.controller.ts index 42fa7359..013efe3d 100644 --- a/packages/mukti-api/src/modules/thought-map/thought-map-dialogue.controller.ts +++ b/packages/mukti-api/src/modules/thought-map/thought-map-dialogue.controller.ts @@ -40,7 +40,6 @@ import { import { DialogueSendMessageDto } from '../dialogue/dto/send-message.dto'; import { DialogueStreamService } from '../dialogue/services/dialogue-stream.service'; import { DialogueService } from '../dialogue/services/dialogue.service'; -import { generateThoughtMapInitialQuestion } from '../dialogue/utils/prompt-builder'; import { ApiGetThoughtMapNodeMessages, ApiSendThoughtMapNodeMessage, @@ -78,11 +77,17 @@ export class ThoughtMapDialogueController { ) {} /** - * Starts dialogue on a Thought Map node with an initial Socratic question. - * Creates the NodeDialogue document if it doesn't already exist. + * Starts dialogue on a Thought Map node. + * + * - If dialogue already has messages → returns existing dialogue + first message (sync). + * - If dialogue is empty (first open) → enqueues an AI job to generate the + * initial Socratic question, returns `{ dialogue, jobId, position }` (async, 202). + * The AI-generated question arrives via the SSE stream. + * + * Marks the node as explored immediately in both cases. */ @ApiStartThoughtMapNodeDialogue() - @HttpCode(HttpStatus.CREATED) + @HttpCode(HttpStatus.ACCEPTED) @Post(':mapId/nodes/:nodeId/start') async startDialogue( @Param('mapId') mapId: string, @@ -92,8 +97,9 @@ export class ThoughtMapDialogueController { // Validate map ownership await this.thoughtMapService.findMapById(mapId, user._id); - // Resolve node info - const { nodeLabel, nodeType } = await this.resolveNodeInfo(mapId, nodeId); + // Resolve full node context (type, label, depth, siblings, parentType) + const { depth, fromSuggestion, nodeLabel, nodeType, parentType, siblings } = + await this.resolveNodeContext(mapId, nodeId); // Get or create dialogue (scoped to mapId+nodeId) const dialogue = @@ -104,7 +110,13 @@ export class ThoughtMapDialogueController { nodeLabel, ); - // If dialogue already has messages, return existing + // Mark the node as explored immediately (idempotent $set) + await this.thoughtNodeModel.updateOne( + { mapId: new Types.ObjectId(mapId), nodeId }, + { $set: { isExplored: true } }, + ); + + // If dialogue already has messages, return existing first message const existingMessages = await this.dialogueService.getMessages( dialogue._id, { @@ -122,30 +134,63 @@ export class ThoughtMapDialogueController { }; } - // Generate initial question based on ThoughtMap node type - const initialQuestionContent = generateThoughtMapInitialQuestion( - nodeType, - nodeLabel, - ); - const initialQuestion = await this.dialogueService.addMessage( - dialogue._id, - 'assistant', - initialQuestionContent, - { model: 'system' }, - ); + // First open: enqueue an AI job to generate the initial Socratic question. + // The user will see this response — use BYOK if available, fall back to server key. + const userRecord = await this.userModel + .findById(user._id) + .select('+openRouterApiKeyEncrypted preferences') + .lean(); + if (!userRecord) { + throw new NotFoundException('User not found'); + } - // Fetch updated dialogue (message count has changed) - const updatedDialogue = - await this.thoughtMapDialogueQueueService.getOrCreateMapDialogue( + const usedByok = !!userRecord.openRouterApiKeyEncrypted; + const serverApiKey = + this.configService.get('OPENROUTER_API_KEY') ?? ''; + if (!usedByok && !serverApiKey) { + throw new InternalServerErrorException( + 'OPENROUTER_API_KEY not configured', + ); + } + + const validationApiKey = usedByok + ? this.aiSecretsService.decryptString( + userRecord.openRouterApiKeyEncrypted!, + ) + : serverApiKey; + + const effectiveModel = await this.aiPolicyService.resolveEffectiveModel({ + hasByok: usedByok, + userActiveModel: userRecord.preferences?.activeModel, + validationApiKey, + }); + + const userWithSubscription = user as User & { subscription?: Subscription }; + const subscriptionTier: 'free' | 'paid' = + userWithSubscription.subscription?.tier === 'paid' ? 'paid' : 'free'; + + const result = + await this.thoughtMapDialogueQueueService.enqueueMapNodeRequest( + user._id, mapId, nodeId, nodeType, nodeLabel, + depth, + fromSuggestion, + siblings, + parentType, + '', // No user message for initial question + subscriptionTier, + effectiveModel, + usedByok, + true, // isInitialQuestion flag ); return { - dialogue: NodeDialogueResponseDto.fromDocument(updatedDialogue), - initialQuestion: DialogueMessageResponseDto.fromDocument(initialQuestion), + dialogue: NodeDialogueResponseDto.fromDocument(dialogue), + jobId: result.jobId, + position: result.position, }; } diff --git a/packages/mukti-web/src/components/canvas/chat/node-chat-panel.tsx b/packages/mukti-web/src/components/canvas/chat/node-chat-panel.tsx index da62e123..d71f384a 100644 --- a/packages/mukti-web/src/components/canvas/chat/node-chat-panel.tsx +++ b/packages/mukti-web/src/components/canvas/chat/node-chat-panel.tsx @@ -19,8 +19,8 @@ import { useCallback, useEffect, useRef } from 'react'; import type { CanvasNode } from '@/types/canvas-visualization.types'; -import { Button } from '@/components/ui/button'; import { LoadingMessage } from '@/components/conversations/loading-message'; +import { Button } from '@/components/ui/button'; import { useDialogueStream, useNodeMessages, useSendNodeMessage } from '@/lib/hooks/use-dialogue'; import { useAiStore } from '@/lib/stores/ai-store'; import { diff --git a/packages/mukti-web/src/components/thought-map/ThoughtMapCanvas.tsx b/packages/mukti-web/src/components/thought-map/ThoughtMapCanvas.tsx index e8dda811..6ee1c91f 100644 --- a/packages/mukti-web/src/components/thought-map/ThoughtMapCanvas.tsx +++ b/packages/mukti-web/src/components/thought-map/ThoughtMapCanvas.tsx @@ -57,7 +57,13 @@ import { useThoughtMapStore, } from '@/lib/stores/thought-map-store'; import { cn } from '@/lib/utils'; -import { computeThoughtMapLayout, type NodePosition } from '@/lib/utils/thought-map-layout'; +import { + centredYPositions, + computeThoughtMapLayout, + GHOST_HORIZONTAL_OFFSET, + GHOST_VERTICAL_SPACING, + type NodePosition, +} from '@/lib/utils/thought-map-layout'; import { ThoughtMapEdge } from './edges/ThoughtMapEdge'; import { EditableBranchNode } from './nodes/EditableBranchNode'; @@ -185,7 +191,10 @@ export function toFlowNodes( * The ghost's ghostId is used as nodeId so onAccept/onDismiss callbacks * (which receive `node.nodeId`) correctly identify which ghost to act on. * - * Position: offset 280px outward (hemisphere-aware) and staggered 90px vertically. + * Position priority: + * 1. `existingPositions[flowNodeId]` — preserves user-dragged or previously computed position + * 2. Freshly computed via `centredYPositions` using GHOST_VERTICAL_SPACING (150px) + * * Ghosts whose parent is not in the store are skipped — they will be cleaned up * by useGhostNodeAutoDismiss on the next render cycle. */ @@ -194,50 +203,60 @@ export function toGhostFlowNodes( storeNodes: Record, layoutPositions: Record, onAccept: (ghostId: string) => void, - onDismiss: (ghostId: string) => void + onDismiss: (ghostId: string) => void, + existingPositions: Record = {} ): RFNode[] { - const ghostIndexByParent = new Map(); - - return ghostNodes.flatMap((ghost) => { - const parent = storeNodes[ghost.parentId]; - - // Skip ghosts whose parent isn't in the store — they have no valid anchor. - if (!parent) { - return []; + // Group ghosts by parent so we can compute centred vertical positions per group + const ghostsByParent = new Map(); + for (const ghost of ghostNodes) { + if (!storeNodes[ghost.parentId]) { + continue; } + const siblings = ghostsByParent.get(ghost.parentId) ?? []; + siblings.push(ghost); + ghostsByParent.set(ghost.parentId, siblings); + } - const parentIndex = ghostIndexByParent.get(ghost.parentId) ?? 0; - ghostIndexByParent.set(ghost.parentId, parentIndex + 1); + const result: RFNode[] = []; + for (const [parentId, siblings] of ghostsByParent) { + const parent = storeNodes[parentId]!; const parentPosition = getDisplayedNodePosition(parent, layoutPositions); - // Mirror the X-offset based on which hemisphere the parent sits in so - // ghosts extend outward rather than toward the canvas centre. - const xOffset = parentPosition.x < 0 ? -280 : 280; + // Extend outward from parent (hemisphere-aware), using wider offset to + // clear the parent node's rendered width (especially the wide TopicNode). + const xOffset = parentPosition.x < 0 ? -GHOST_HORIZONTAL_OFFSET : GHOST_HORIZONTAL_OFFSET; const baseX = parentPosition.x + xOffset; - const baseY = parentPosition.y + parentIndex * 90 - 45; - - // Synthetic ThoughtMapNode for QuestionNode rendering - const now = new Date().toISOString(); - const syntheticNode: ThoughtMapNode = { - createdAt: now, - depth: parent.depth + 1, - fromSuggestion: true, - id: ghost.ghostId, - isCollapsed: false, - isExplored: false, - label: ghost.label, - mapId: parent.mapId, - messageCount: 0, - nodeId: ghost.ghostId, - parentNodeId: ghost.parentId, - position: { x: baseX, y: baseY }, - type: 'question', - updatedAt: now, - }; - return [ - { + // Compute evenly centred Y positions for the sibling group + const yPositions = centredYPositions(siblings.length, parentPosition.y, GHOST_VERTICAL_SPACING); + + for (let i = 0; i < siblings.length; i++) { + const ghost = siblings[i]; + const flowId = ghostNodeToFlowNodeId(ghost); + + // Use existing (persisted/dragged) position if available, otherwise compute fresh + const position: NodePosition = existingPositions[flowId] ?? { x: baseX, y: yPositions[i] }; + + const now = new Date().toISOString(); + const syntheticNode: ThoughtMapNode = { + createdAt: now, + depth: parent.depth + 1, + fromSuggestion: true, + id: ghost.ghostId, + isCollapsed: false, + isExplored: false, + label: ghost.label, + mapId: parent.mapId, + messageCount: 0, + nodeId: ghost.ghostId, + parentNodeId: ghost.parentId, + position: { x: position.x, y: position.y }, + type: 'question', + updatedAt: now, + }; + + result.push({ data: { ghostCreatedAt: ghost.createdAt, isGhost: true, @@ -245,12 +264,14 @@ export function toGhostFlowNodes( onAccept, onDismiss, }, - id: ghostNodeToFlowNodeId(ghost), - position: { x: baseX, y: baseY }, + id: flowId, + position: { x: position.x, y: position.y }, type: 'question-node', - } satisfies RFNode, - ]; - }); + } satisfies RFNode); + } + } + + return result; } function getDisplayedNodePosition( @@ -283,6 +304,8 @@ function ThoughtMapCanvasInner({ mapId }: ThoughtMapCanvasInnerProps) { const isDraggingRef = useRef(false); // Capture inline edit node position so the committed node appears in the same spot const inlineEditPositionRef = useRef(null); + // Persist ghost node positions across re-renders so they survive accept/dismiss of siblings + const ghostPositionsRef = useRef>({}); // ---- Sync remote data into the Zustand store -------------------------------- useEffect(() => { @@ -301,7 +324,9 @@ function ThoughtMapCanvasInner({ mapId }: ThoughtMapCanvasInnerProps) { // ---- Ghost node callbacks --------------------------------------------------- const handleAcceptGhost = useCallback( (ghostId: string) => { - void acceptGhostNode(ghostId); + const flowId = `ghost-${ghostId}`; + const position = ghostPositionsRef.current[flowId]; + void acceptGhostNode(ghostId, position); }, [acceptGhostNode] ); @@ -425,37 +450,44 @@ function ThoughtMapCanvasInner({ mapId }: ThoughtMapCanvasInnerProps) { storeNodes, ]); - const flowNodes = useMemo( - () => [ - ...toFlowNodes( - domainNodesList, - layoutPositions, - handleAddBranch, - handleSuggestBranches, - handleDeleteNode - ), - ...toGhostFlowNodes( - ghostNodes, - storeNodes, - layoutPositions, - handleAcceptGhost, - handleDismissGhost - ), - ...inlineEditNode, - ], - [ + const flowNodes = useMemo(() => { + const realNodes = toFlowNodes( domainNodesList, - ghostNodes, - handleAcceptGhost, + layoutPositions, handleAddBranch, - handleDeleteNode, - handleDismissGhost, handleSuggestBranches, - inlineEditNode, - layoutPositions, + handleDeleteNode + ); + const ghostFlowNodes = toGhostFlowNodes( + ghostNodes, storeNodes, - ] - ); + layoutPositions, + handleAcceptGhost, + handleDismissGhost, + ghostPositionsRef.current + ); + + // Sync newly computed ghost positions back into the ref so they persist + // across re-renders (e.g. when a sibling ghost is accepted/dismissed). + const nextPositions: Record = {}; + for (const gfn of ghostFlowNodes) { + nextPositions[gfn.id] = { x: gfn.position.x, y: gfn.position.y }; + } + ghostPositionsRef.current = nextPositions; + + return [...realNodes, ...ghostFlowNodes, ...inlineEditNode]; + }, [ + domainNodesList, + ghostNodes, + handleAcceptGhost, + handleAddBranch, + handleDeleteNode, + handleDismissGhost, + handleSuggestBranches, + inlineEditNode, + layoutPositions, + storeNodes, + ]); // ---- Temporary edge for inline edit node ----------------------------------- const inlineEditEdge = useMemo((): RFEdge[] => { @@ -499,7 +531,7 @@ function ThoughtMapCanvasInner({ mapId }: ThoughtMapCanvasInnerProps) { isDraggingRef.current = true; }, []); - // ---- Drag stop → persist position to store ---------------------------------- + // ---- Drag stop → persist position to store or ghost ref ----------------------- const handleNodeDragStop: RFOnNodeDrag = useCallback( (_event, node) => { isDraggingRef.current = false; @@ -508,6 +540,16 @@ function ThoughtMapCanvasInner({ mapId }: ThoughtMapCanvasInnerProps) { if (node.id === '__inline-edit__') { return; } + + // Ghost nodes: persist to ref instead of calling updateNode (no backend) + if (node.id.startsWith('ghost-')) { + ghostPositionsRef.current = { + ...ghostPositionsRef.current, + [node.id]: { x: node.position.x, y: node.position.y }, + }; + return; + } + void updateNode(node.id, { x: node.position.x, y: node.position.y }); }, [updateNode] diff --git a/packages/mukti-web/src/components/thought-map/ThoughtMapDialoguePanel.tsx b/packages/mukti-web/src/components/thought-map/ThoughtMapDialoguePanel.tsx index 0aa4910e..50e36306 100644 --- a/packages/mukti-web/src/components/thought-map/ThoughtMapDialoguePanel.tsx +++ b/packages/mukti-web/src/components/thought-map/ThoughtMapDialoguePanel.tsx @@ -139,7 +139,10 @@ export function ThoughtMapDialoguePanel({ mapId, node, onClose }: ThoughtMapDial // Flatten pages const messages = messagesData?.pages.flatMap((page) => page.messages) ?? []; const messageCount = messagesData?.pages[0]?.dialogue?.messageCount ?? 0; + const hasDialogue = messagesData?.pages[0]?.dialogue !== null; const hasHistory = messages.length > 0; + const isGeneratingInitialQuestion = hasDialogue && !hasHistory; + const showProcessingLoader = isProcessing && hasHistory; const nodeConfig = NODE_TYPE_CONFIG[node.type] ?? NODE_TYPE_CONFIG.topic; const NodeIcon = nodeConfig.icon; @@ -254,15 +257,15 @@ export function ThoughtMapDialoguePanel({ mapId, node, onClose }: ThoughtMapDial

{node.label}

- {/* Start dialogue button — shown when no history exists */} - {!hasHistory && !isStarting && !isLoadingMessages && ( + {/* Start dialogue button — shown when no dialogue exists */} + {!hasDialogue && !isStarting && !isLoadingMessages && ( )} - {/* Loading state for start */} + {/* Loading state for start mutation in-flight */} {isStarting && (
@@ -270,6 +273,14 @@ export function ThoughtMapDialoguePanel({ mapId, node, onClose }: ThoughtMapDial
)} + {/* Generating state — dialogue created, AI producing initial question */} + {!isStarting && isGeneratingInitialQuestion && ( +
+ + Generating initial question… +
+ )} + {/* Continue hint — shown when history exists */} {hasHistory && (

@@ -322,7 +333,9 @@ export function ThoughtMapDialoguePanel({ mapId, node, onClose }: ThoughtMapDial ))} {/* Processing indicator */} - {isProcessing && } + {showProcessingLoader && ( + + )} {/* Scroll anchor */}

diff --git a/packages/mukti-web/src/components/thought-map/__tests__/thought-map-canvas.test.ts b/packages/mukti-web/src/components/thought-map/__tests__/thought-map-canvas.test.ts index 97a25ce4..6b89e475 100644 --- a/packages/mukti-web/src/components/thought-map/__tests__/thought-map-canvas.test.ts +++ b/packages/mukti-web/src/components/thought-map/__tests__/thought-map-canvas.test.ts @@ -1,7 +1,11 @@ import type { GhostNode } from '@/lib/stores/thought-map-store'; import type { ThoughtMapNode } from '@/types/thought-map'; -import { computeThoughtMapLayout } from '@/lib/utils/thought-map-layout'; +import { + computeThoughtMapLayout, + GHOST_HORIZONTAL_OFFSET, + GHOST_VERTICAL_SPACING, +} from '@/lib/utils/thought-map-layout'; import { toFlowNodes, toGhostFlowNodes } from '../ThoughtMapCanvas'; @@ -84,8 +88,10 @@ describe('ThoughtMapCanvas helpers', () => { ); expect(ghostNodes[0]?.position).toEqual({ - x: displayedParent.x + (displayedParent.x < 0 ? -280 : 280), - y: displayedParent.y - 45, + x: + displayedParent.x + + (displayedParent.x < 0 ? -GHOST_HORIZONTAL_OFFSET : GHOST_HORIZONTAL_OFFSET), + y: displayedParent.y, }); expect(ghostNodes[0]?.data).toMatchObject({ isGhost: true }); }); @@ -151,8 +157,12 @@ describe('ThoughtMapCanvas helpers', () => { jest.fn() ); - expect(flowGhosts[0]?.position.y).toBe(leftPosition.y - 45); - expect(flowGhosts[1]?.position.y).toBe(rightPosition.y - 45); - expect(flowGhosts[2]?.position.y).toBe(leftPosition.y + 45); + // Ghosts are grouped by parent: left ghosts [0,1] then right ghost [2]. + // centredYPositions(2, leftY, 150) → [leftY - 75, leftY + 75] + // centredYPositions(1, rightY, 150) → [rightY] + const halfGhostSpacing = GHOST_VERTICAL_SPACING / 2; + expect(flowGhosts[0]?.position.y).toBe(leftPosition.y - halfGhostSpacing); + expect(flowGhosts[1]?.position.y).toBe(leftPosition.y + halfGhostSpacing); + expect(flowGhosts[2]?.position.y).toBe(rightPosition.y); }); }); diff --git a/packages/mukti-web/src/components/thought-map/nodes/QuestionNode.tsx b/packages/mukti-web/src/components/thought-map/nodes/QuestionNode.tsx index d3d1d881..b10db007 100644 --- a/packages/mukti-web/src/components/thought-map/nodes/QuestionNode.tsx +++ b/packages/mukti-web/src/components/thought-map/nodes/QuestionNode.tsx @@ -3,23 +3,24 @@ /** * QuestionNode component for Thought Map canvas * - * Represents an AI-suggested question node — a prompt that hasn't been - * accepted into the map yet. Visually distinct: dashed border, muted/ghost - * styling, Sparkles icon. Non-functional Accept/Dismiss buttons appear on hover. + * Renders both ghost (AI-suggested) and accepted question nodes. * - * Features: - * - Dashed border + reduced opacity (ghost state) - * - Sparkles icon indicating AI origin - * - Hoverable Accept / Dismiss action buttons (visual only — wired in canvas) - * - Target handle on left, source handle on right - * - Japandi aesthetic: very muted lavender-tinged stone tones - * - Animated countdown background sweep on ghost nodes (moves horizontally over - * the 60s auto-dismiss window; falls back to plain text when prefers-reduced-motion) + * Ghost state: + * - Dashed border, muted opacity, Sparkles badge, Accept/Dismiss buttons on hover + * - Countdown background sweep (60s auto-dismiss window) + * - "Ink solidifies" acceptance animation: + * 1. Buttons fade out → badge fades up → SVG ink stroke draws around border → card fades out + * 2. After 500ms, onAccept() fires so the real node appears in its place + * + * Accepted state (fromSuggestion && createdAt within 2s): + * - Spring entrance: scale 0.94→1, opacity 0.5→1 + * - Respects prefers-reduced-motion (skips all animations, fires onAccept immediately) */ import { Handle, Position } from '@xyflow/react'; +import { motion, useReducedMotion } from 'framer-motion'; import { Check, Sparkles, Trash2, X } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { ThoughtMapNode } from '@/types/thought-map'; @@ -38,6 +39,9 @@ import { cn } from '@/lib/utils'; /** Must match GHOST_AUTO_DISMISS_MS in use-thought-map-suggestions.ts */ const GHOST_AUTO_DISMISS_MS = 60_000; +/** How recently a node must have been created to play the entrance animation */ +const ENTRANCE_ANIMATION_WINDOW_MS = 2_000; + // ============================================================================ // Types // ============================================================================ @@ -68,17 +72,32 @@ export interface QuestionNodeProps { selected?: boolean; } +interface AcceptedQuestionNodeProps { + node: ThoughtMapNode; + onDeleteNode?: (nodeId: string) => void; + selected?: boolean; + side: 'left' | 'right'; +} + +interface GhostNodeProps { + ghostCreatedAt?: number; + node: ThoughtMapNode; + onAccept?: (nodeId: string) => void; + onDismiss?: (nodeId: string) => void; + selected?: boolean; + side: 'left' | 'right'; +} + // ============================================================================ -// useGhostCountdown +// Helpers // ============================================================================ /** - * QuestionNode - AI-suggested question node in the Thought Map + * QuestionNode — React Flow custom node for AI-suggested questions * - * Renders as a ghost card with dashed border to signal provisional / pending state. - * Accept and Dismiss actions are shown on hover and wired via callbacks. + * Routes to GhostNode (isGhost=true) or AcceptedQuestionNode (isGhost=false). * - * @param data - Node data including the ThoughtMapNode and optional action callbacks + * @param data - Node data including callbacks and node metadata * @param selected - Whether the node is currently selected in React Flow */ export function QuestionNode({ data, selected }: QuestionNodeProps) { @@ -91,185 +110,90 @@ export function QuestionNode({ data, selected }: QuestionNodeProps) { onDismiss, side = 'right', } = data; - const [hovered, setHovered] = useState(false); - const handleAccept = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onAccept?.(node.nodeId); - }, - [node.nodeId, onAccept] - ); + if (isGhost) { + return ( + + ); + } - const handleDismiss = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onDismiss?.(node.nodeId); - }, - [node.nodeId, onDismiss] + return ( + ); +} + +// ============================================================================ +// QuestionNode — public export (orchestrates ghost vs accepted state) +// ============================================================================ + +function AcceptedQuestionNode({ node, onDeleteNode, selected, side }: AcceptedQuestionNodeProps) { + const prefersReducedMotion = useReducedMotion(); const handleDelete = useCallback(() => { onDeleteNode?.(node.nodeId); }, [node.nodeId, onDeleteNode]); + // Only play entrance animation for nodes accepted within the last 2 seconds + const isNew = + !prefersReducedMotion && + Date.now() - new Date(node.createdAt).getTime() < ENTRANCE_ANIMATION_WINDOW_MS; + const nodeContent = ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} + initial={isNew ? { opacity: 0.5, scale: 0.94 } : false} + transition={isNew ? { damping: 20, stiffness: 280, type: 'spring' } : undefined} > - {/* Countdown background — ghost nodes only */} - {isGhost && ghostCreatedAt !== undefined && ( - - )} - - {isGhost ? ( -
- - - Suggestion - -
- ) : ( -
- - - Question - -
- )} - - {/* Question label */} -

+

+ + + Question + +
+ +

{node.label}

- {isGhost && ( -
- - -
- )} - - {/* React Flow handles: side-aware for correct edge routing in radial layout */} - {side === 'left' ? ( - <> - - - - ) : ( - <> - - - - )} -
+ + ); - // Ghost nodes don't get a context menu - if (isGhost) { + if (!onDeleteNode) { return nodeContent; } - // Non-ghost question nodes get a context menu with Delete return ( {nodeContent} - {onDeleteNode && ( - - - Delete - - )} + + + Delete + ); } // ============================================================================ -// GhostCountdownBackground +// GhostNode — ink-solidifies acceptance animation // ============================================================================ /** @@ -316,7 +240,216 @@ function GhostCountdownBackground({ createdAt, hovered }: { createdAt: number; h } // ============================================================================ -// QuestionNode +// AcceptedQuestionNode — spring entrance for freshly accepted nodes +// ============================================================================ + +function GhostNode({ ghostCreatedAt, node, onAccept, onDismiss, selected, side }: GhostNodeProps) { + const prefersReducedMotion = useReducedMotion(); + const [hovered, setHovered] = useState(false); + const [isAccepting, setIsAccepting] = useState(false); + const containerRef = useRef(null); + const [nodeSize, setNodeSize] = useState(null); + + // Measure node dimensions after mount for the SVG overlay + useEffect(() => { + if (!containerRef.current) { + return; + } + const { offsetHeight, offsetWidth } = containerRef.current; + if (offsetWidth > 0 && offsetHeight > 0) { + setNodeSize({ h: offsetHeight, w: offsetWidth }); + } + }, []); + + const handleAccept = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (prefersReducedMotion) { + onAccept?.(node.nodeId); + return; + } + setIsAccepting(true); + // Delay actual acceptance until ink animation completes + setTimeout(() => onAccept?.(node.nodeId), 500); + }, + [node.nodeId, onAccept, prefersReducedMotion] + ); + + const handleDismiss = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onDismiss?.(node.nodeId); + }, + [node.nodeId, onDismiss] + ); + + const borderRadius = 12; // matches rounded-xl (12px) + const svgPadding = 2; // inset to sit just on top of the border + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + ref={containerRef} + style={isAccepting ? { opacity: 0.7 } : { opacity: hovered || selected ? 1 : 0.7 }} + transition={isAccepting ? { delay: 0.46, duration: 0.12 } : undefined} + > + {/* Countdown background — ghost nodes only */} + {ghostCreatedAt !== undefined && ( + + )} + + {/* Ink stroke SVG overlay — draws around border on accept */} + {nodeSize && !prefersReducedMotion && ( + + + + )} + + {/* "Suggestion" badge — fades out when accepting */} + + + + Suggestion + + + + {/* Question label */} +

+ {node.label} +

+ + {/* Accept / Dismiss buttons — fade out immediately when accepting */} + + + + + + +
+ ); +} + +// ============================================================================ +// NodeHandles — side-aware React Flow handles +// ============================================================================ + +function NodeHandles({ isGhost, side }: { isGhost: boolean; side: 'left' | 'right' }) { + const handleClass = cn( + '!h-2.5 !w-2.5', + isGhost + ? '!border-slate-300 !bg-slate-200 dark:!border-slate-600 dark:!bg-slate-700' + : '!border-stone-300 !bg-stone-200 dark:!border-stone-600 dark:!bg-stone-700' + ); + + if (side === 'left') { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +} + +// ============================================================================ +// GhostCountdownBackground +// ============================================================================ + +/** + * Generates an SVG path string for a rounded rectangle. + * Required because supports pathLength animation; does not. + */ +function roundedRectPath(w: number, h: number, r: number): string { + return [ + `M ${r} 0`, + `L ${w - r} 0`, + `Q ${w} 0 ${w} ${r}`, + `L ${w} ${h - r}`, + `Q ${w} ${h} ${w - r} ${h}`, + `L ${r} ${h}`, + `Q 0 ${h} 0 ${h - r}`, + `L 0 ${r}`, + `Q 0 0 ${r} 0`, + 'Z', + ].join(' '); +} + +// ============================================================================ +// useGhostCountdown // ============================================================================ /** diff --git a/packages/mukti-web/src/lib/api/thought-map-dialogue.ts b/packages/mukti-web/src/lib/api/thought-map-dialogue.ts index 404ff29f..d66b18cb 100644 --- a/packages/mukti-web/src/lib/api/thought-map-dialogue.ts +++ b/packages/mukti-web/src/lib/api/thought-map-dialogue.ts @@ -91,7 +91,9 @@ interface BackendSendMessageResponse { interface BackendStartDialogueResponse { dialogue: BackendThoughtMapDialogue; - initialQuestion: BackendThoughtMapDialogueMessage; + initialQuestion?: BackendThoughtMapDialogueMessage; + jobId?: string; + position?: number; } interface BackendThoughtMapDialogue { @@ -226,10 +228,25 @@ export const thoughtMapDialogueApi = { `/thought-maps/${mapId}/nodes/${nodeId}/start` ); - return { - dialogue: transformDialogue(response.dialogue), - initialQuestion: transformMessage(response.initialQuestion), - }; + const dialogue = transformDialogue(response.dialogue); + + // Async path: new dialogue, AI generating initial question via queue + if (response.jobId) { + if (response.position === null || response.position === undefined) { + throw new Error( + 'startDialogue async path: response.position is missing when jobId is present' + ); + } + return { dialogue, jobId: response.jobId, position: response.position }; + } + + // Sync path: existing dialogue, return first message + if (!response.initialQuestion) { + throw new Error( + 'startDialogue sync path: response.initialQuestion is missing when no jobId is present' + ); + } + return { dialogue, initialQuestion: transformMessage(response.initialQuestion) }; }, /** diff --git a/packages/mukti-web/src/lib/api/thought-map.ts b/packages/mukti-web/src/lib/api/thought-map.ts index f4c944d3..fe5e8b86 100644 --- a/packages/mukti-web/src/lib/api/thought-map.ts +++ b/packages/mukti-web/src/lib/api/thought-map.ts @@ -314,7 +314,8 @@ export const thoughtMapApi = { * ``` */ createThoughtNode: async (dto: CreateThoughtNodeRequest): Promise => { - const { mapId, parentNodeId, ...body } = dto; + // Strip x/y — backend DTO does not accept position on create (position is always {0,0} server-side) + const { mapId, parentNodeId, x: _x, y: _y, ...body } = dto; const response = await apiClient.post(`/thought-maps/${mapId}/nodes`, { ...body, parentId: parentNodeId, diff --git a/packages/mukti-web/src/lib/hooks/use-thought-map-dialogue.ts b/packages/mukti-web/src/lib/hooks/use-thought-map-dialogue.ts index 6bae0b0e..fde479e8 100644 --- a/packages/mukti-web/src/lib/hooks/use-thought-map-dialogue.ts +++ b/packages/mukti-web/src/lib/hooks/use-thought-map-dialogue.ts @@ -29,8 +29,9 @@ import { type ThoughtMapSendMessageResponse, } from '@/lib/api/thought-map-dialogue'; import { config } from '@/lib/config'; -import { thoughtMapDialogueKeys } from '@/lib/query-keys'; +import { thoughtMapDialogueKeys, thoughtMapKeys } from '@/lib/query-keys'; import { useAuthStore } from '@/lib/stores/auth-store'; +import { useThoughtMapStore } from '@/lib/stores/thought-map-store'; // ============================================================================ // Types @@ -156,18 +157,54 @@ export function useStartThoughtMapDialogue(mapId: string, nodeId: string) { onSuccess: (response) => { const queryKey = thoughtMapDialogueKeys.messages(mapId, nodeId); - // Reload case: dialogue already has more than the initial question. - // Seeding the cache with only one message would render it as the sole - // visible message while the count badge correctly shows the true total. - // Invalidate so useThoughtMapNodeMessages fetches the full paginated list. + // Optimistically mark the node as explored in the Zustand store. + // The backend already persists isExplored=true in the start endpoint, + // so this is a local-only UI update — no API call needed. + useThoughtMapStore.setState((state) => { + const existingNode = state.nodes[nodeId]; + if (!existingNode || existingNode.isExplored) { + return state; + } + return { + nodes: { + ...state.nodes, + [nodeId]: { ...existingNode, isExplored: true }, + }, + }; + }); + + // Async path: AI is generating the initial question via the queue. + // Seed cache with the dialogue but no messages — the SSE stream + // will deliver the AI message when it's ready. + if ('jobId' in response) { + const seedPage: ThoughtMapPaginatedMessagesResponse = { + dialogue: response.dialogue, + messages: [], + pagination: { + hasMore: false, + limit: config.pagination.defaultPageSize, + page: 1, + total: 0, + totalPages: 0, + }, + }; + + queryClient.setQueryData>(queryKey, { + pageParams: [1], + pages: [seedPage], + }); + return; + } + + // Sync path: dialogue already has messages (re-open case). + // Invalidate if more than the initial question exists so the + // infinite query fetches the full paginated list. if (response.dialogue.messageCount > 1) { queryClient.invalidateQueries({ queryKey }); return; } - // First-open case: only the initial question exists. - // Seed the cache so the infinite query shows it immediately - // without a redundant network round-trip. + // Sync path with single message: seed the cache directly. const existing = queryClient.getQueryData>(queryKey); @@ -290,6 +327,11 @@ export function useThoughtMapDialogueStream( queryClient.invalidateQueries({ queryKey: thoughtMapDialogueKeys.node(currentMapId, currentNodeId), }); + // Also invalidate the map detail cache so isExplored and other + // node-level changes (set by the queue worker) are reflected. + queryClient.invalidateQueries({ + queryKey: thoughtMapKeys.detail(currentMapId), + }); break; case 'error': { diff --git a/packages/mukti-web/src/lib/stores/thought-map-store.ts b/packages/mukti-web/src/lib/stores/thought-map-store.ts index 15ad8f0a..0c48f5aa 100644 --- a/packages/mukti-web/src/lib/stores/thought-map-store.ts +++ b/packages/mukti-web/src/lib/stores/thought-map-store.ts @@ -68,7 +68,7 @@ interface ThoughtMapState { * Accept a ghost node: persists it as a real node via addNode, then removes the ghost. * @returns The new node's nodeId, or null if failed */ - acceptGhostNode: (ghostId: string) => Promise; + acceptGhostNode: (ghostId: string, position?: { x: number; y: number }) => Promise; /** * Add a ghost node (AI suggestion) to the canvas. @@ -319,7 +319,10 @@ export const useThoughtMapStore = create()((set, get) => ({ /** * Accept a ghost node: persists it as a real node, then removes the ghost. */ - acceptGhostNode: async (ghostId: string): Promise => { + acceptGhostNode: async ( + ghostId: string, + position?: { x: number; y: number } + ): Promise => { const { ghostNodes, map } = get(); const ghost = ghostNodes[ghostId]; @@ -334,10 +337,14 @@ export const useThoughtMapStore = create()((set, get) => ({ mapId: map.id, parentNodeId: ghost.parentId, type: ghost.suggestedType, + x: position?.x, + y: position?.y, }); - // Remove the ghost regardless of success - get().removeGhostNode(ghostId); + // Only remove the ghost if the node was created successfully + if (nodeId) { + get().removeGhostNode(ghostId); + } return nodeId; }, @@ -390,13 +397,32 @@ export const useThoughtMapStore = create()((set, get) => ({ try { const created = await thoughtMapApi.createThoughtNode(dto); - // Replace temp node with real node from server + // Replace temp node with real node from server. + // If a position was provided in the DTO, override the server's default {x:0, y:0} + // so the node stays at the intended location (e.g. where a ghost was displayed). + // The layout algorithm only repositions nodes at origin, so a non-zero position + // is treated as user-placed and left untouched. const currentNodes = get().nodes; const { [tempNodeId]: _removed, ...rest } = currentNodes; + const nodeToStore: ThoughtMapNode = + dto.x !== undefined && dto.y !== undefined + ? { ...created, position: { x: dto.x, y: dto.y } } + : created; set({ - nodes: { ...rest, [created.nodeId]: created }, + nodes: { ...rest, [nodeToStore.nodeId]: nodeToStore }, }); + // Persist the intended position to the backend so it survives a page reload. + // Fire-and-forget: the node is already displayed at the correct position in the + // store; a failure here only means the position reverts on next load. + if (dto.x !== undefined && dto.y !== undefined) { + void thoughtMapApi + .updateThoughtNode(dto.mapId, created.nodeId, { x: dto.x, y: dto.y }) + .catch((err) => { + console.error('Failed to persist node position after create:', err); + }); + } + return created.nodeId; } catch (err) { // Rollback: remove temp node @@ -566,10 +592,10 @@ export const useThoughtMapStore = create()((set, get) => ({ } catch (err) { // Rollback console.error('Failed to delete thought node:', err); - set({ - ghostNodes: { ...get().ghostNodes, ...ghostNodes }, - nodes: { ...get().nodes, ...snapshotNodes }, - }); + set((state) => ({ + ghostNodes: { ...state.ghostNodes, ...ghostNodes }, + nodes: { ...state.nodes, ...snapshotNodes }, + })); return false; } }, diff --git a/packages/mukti-web/src/lib/utils/thought-map-layout.ts b/packages/mukti-web/src/lib/utils/thought-map-layout.ts index c7d312dc..8f545c05 100644 --- a/packages/mukti-web/src/lib/utils/thought-map-layout.ts +++ b/packages/mukti-web/src/lib/utils/thought-map-layout.ts @@ -26,6 +26,22 @@ export const HORIZONTAL_SPACING = 250; /** Vertical distance between sibling nodes at the same depth */ export const VERTICAL_SPACING = 120; +/** + * Vertical distance between ghost (suggestion) nodes sharing the same parent. + * Ghost nodes are taller than real nodes (~130–150px with SUGGESTION header, + * label, and Accept/Dismiss buttons), so they need more breathing room than + * the standard VERTICAL_SPACING (120px). + */ +export const GHOST_VERTICAL_SPACING = 150; + +/** + * Horizontal offset from parent for ghost (suggestion) nodes. + * Larger than HORIZONTAL_SPACING to clear the widest parent node (TopicNode + * at max-w-[320px]). React Flow positions are top-left anchored, so an offset + * of 360px provides ~40px clearance from a fully-expanded topic node. + */ +export const GHOST_HORIZONTAL_OFFSET = 360; + // ============================================================================ // Types // ============================================================================ @@ -40,6 +56,36 @@ export interface NodePosition { // Main export // ============================================================================ +/** + * Compute vertical positions for a group of siblings, centred around a given y value. + * + * @param count - Number of siblings + * @param centreY - The y-coordinate to centre the group around + * @param spacing - Vertical spacing between siblings (defaults to VERTICAL_SPACING) + * @returns Array of y-values for each sibling + */ +export function centredYPositions( + count: number, + centreY: number, + spacing: number = VERTICAL_SPACING +): number[] { + if (count === 0) { + return []; + } + if (count === 1) { + return [centreY]; + } + + const totalHeight = (count - 1) * spacing; + const startY = centreY - totalHeight / 2; + + return Array.from({ length: count }, (_, i) => startY + i * spacing); +} + +// ============================================================================ +// Helpers +// ============================================================================ + /** * Compute layout positions for all unpositioned nodes in a Thought Map. * @@ -143,31 +189,6 @@ export function computeThoughtMapLayout(nodes: ThoughtMapNode[]): Record startY + i * VERTICAL_SPACING); -} - /** * Determine the direction (left or right) for a depth-1 node at a given index * among all depth-1 nodes. diff --git a/packages/mukti-web/src/types/thought-map.ts b/packages/mukti-web/src/types/thought-map.ts index 35ffd7ca..047d95ed 100644 --- a/packages/mukti-web/src/types/thought-map.ts +++ b/packages/mukti-web/src/types/thought-map.ts @@ -256,9 +256,30 @@ export interface ThoughtMapShareLink { } /** - * Response from starting a Thought Map node dialogue. + * Async response from starting a new Thought Map node dialogue. + * The AI initial question is being generated via the queue — + * subscribe to the SSE stream for the message. */ -export interface ThoughtMapStartDialogueResponse { +export interface ThoughtMapStartDialogueAsyncResponse { + dialogue: ThoughtMapDialogue; + jobId: string; + position: number; +} + +/** + * Union response from starting a Thought Map node dialogue. + * - Async (has `jobId`): new dialogue, AI generating initial question via queue + * - Sync (has `initialQuestion`): existing dialogue, returns first message + */ +export type ThoughtMapStartDialogueResponse = + | ThoughtMapStartDialogueAsyncResponse + | ThoughtMapStartDialogueSyncResponse; + +/** + * Sync response when a Thought Map node dialogue already has messages. + * Returns the first message directly (no queue job needed). + */ +export interface ThoughtMapStartDialogueSyncResponse { dialogue: ThoughtMapDialogue; initialQuestion: ThoughtMapDialogueMessage; }