messages.js 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /**
  2. * Unified session messages endpoint (PilotDeck-only).
  3. *
  4. * GET /api/sessions/:sessionId/messages?projectName=&projectPath=&limit=&offset=
  5. *
  6. * Reads transcripts through the gateway's `readSessionMessages` RPC.
  7. * Previously this route imported `readWebSessionMessages` directly from
  8. * `dist/src/web/server/` — that coupled `ui/server/` to compiled
  9. * artifacts and meant `src/` edits were silently invisible until a
  10. * `npm run build`. Going through the gateway WebSocket means the
  11. * standalone `pilotdeck server` process owns the read path and we pick
  12. * up its in-flight session writes automatically.
  13. *
  14. * @module routes/messages
  15. */
  16. import express from 'express';
  17. import { getPilotDeckGateway } from '../pilotdeck-bridge.js';
  18. import { createNormalizedMessage } from '../pilotdeck-message.js';
  19. const router = express.Router();
  20. const REPO_ROOT = process.cwd();
  21. router.get('/:sessionId/messages', async (req, res) => {
  22. try {
  23. const { sessionId } = req.params;
  24. const projectPath = String(req.query.projectPath || req.query.projectName || REPO_ROOT);
  25. const limitParam = req.query.limit;
  26. const limit = limitParam !== undefined && limitParam !== null && limitParam !== ''
  27. ? parseInt(limitParam, 10)
  28. : null;
  29. const offset = parseInt(req.query.offset || '0', 10);
  30. const gateway = await getPilotDeckGateway();
  31. const result = await gateway.readSessionMessages({
  32. sessionKey: sessionId,
  33. projectKey: projectPath,
  34. limit: limit ?? undefined,
  35. cursor: offset > 0 ? String(offset) : undefined,
  36. });
  37. const messages = result.messages.map((message) => mapWebMessageToNormalized(message, sessionId));
  38. const totalKnown = typeof result.total === 'number' ? result.total : messages.length + offset;
  39. const hasMore = result.nextCursor !== undefined && result.nextCursor !== null;
  40. return res.json({
  41. messages,
  42. total: totalKnown,
  43. hasMore,
  44. offset,
  45. limit,
  46. });
  47. } catch (error) {
  48. console.error('[messages] read_session_messages failed:', error);
  49. return res.json({ messages: [], total: 0, hasMore: false, offset: 0, limit: null });
  50. }
  51. });
  52. function mapWebMessageToNormalized(message, sessionId) {
  53. const base = {
  54. id: message.id,
  55. sessionId,
  56. timestamp: message.createdAt,
  57. provider: message.provider || 'pilotdeck',
  58. };
  59. switch (message.kind) {
  60. case 'text':
  61. return createNormalizedMessage({
  62. ...base,
  63. kind: 'text',
  64. role: message.role === 'user' ? 'user' : 'assistant',
  65. content: message.text || '',
  66. ...(Array.isArray(message.images) && message.images.length > 0
  67. ? { images: message.images.map((image) => image?.data).filter(Boolean) }
  68. : {}),
  69. });
  70. case 'thinking':
  71. return createNormalizedMessage({ ...base, kind: 'thinking', content: message.text || '' });
  72. case 'tool_use':
  73. return createNormalizedMessage({
  74. ...base,
  75. kind: 'tool_use',
  76. toolName: message.toolName,
  77. toolInput: message.payload,
  78. toolId: message.toolCallId,
  79. });
  80. case 'tool_result': {
  81. const planPayload = message.payload && typeof message.payload === 'object'
  82. ? message.payload
  83. : {};
  84. return createNormalizedMessage({
  85. ...base,
  86. kind: 'tool_result',
  87. toolId: message.toolCallId,
  88. content: message.text || '',
  89. isError: message.ok === false,
  90. ...(message.errorCode ? { errorCode: message.errorCode } : {}),
  91. // Inline tool-result images (e.g. read_file on a PNG). The web
  92. // server already wraps the bare base64 from canonical messages as
  93. // data URLs in `toWebMessageImage`, so just pass them through.
  94. ...(Array.isArray(message.images) && message.images.length > 0
  95. ? {
  96. toolResultImages: message.images
  97. .filter((image) => image && typeof image.data === 'string')
  98. .map((image) => ({ data: image.data, mimeType: image.mimeType })),
  99. }
  100. : {}),
  101. ...(planPayload.planFilePath ? {
  102. planFilePath: planPayload.planFilePath,
  103. planTitle: planPayload.planTitle,
  104. planSummary: planPayload.planSummary,
  105. } : {}),
  106. });
  107. }
  108. case 'permission_request':
  109. return createNormalizedMessage({
  110. ...base,
  111. kind: 'permission_request',
  112. requestId: message.requestId,
  113. toolName: message.toolName,
  114. input: message.payload,
  115. });
  116. case 'elicitation_request':
  117. return createNormalizedMessage({
  118. ...base,
  119. kind: 'interactive_prompt',
  120. requestId: message.requestId,
  121. content: '',
  122. });
  123. case 'structured_output':
  124. return createNormalizedMessage({
  125. ...base,
  126. kind: 'status',
  127. text: 'structured',
  128. payload: message.payload,
  129. });
  130. case 'status':
  131. return createNormalizedMessage({ ...base, kind: 'status', text: message.text || '' });
  132. case 'complete':
  133. return createNormalizedMessage({ ...base, kind: 'complete' });
  134. case 'error':
  135. return createNormalizedMessage({ ...base, kind: 'error', content: message.text || '' });
  136. case 'interrupted':
  137. return createNormalizedMessage({ ...base, kind: 'interrupted', content: message.text || '' });
  138. case 'compact_boundary': {
  139. const payload = message.payload || {};
  140. return createNormalizedMessage({
  141. ...base,
  142. kind: 'compact_boundary',
  143. trigger: payload.trigger || 'auto',
  144. preTokens: payload.preTokens,
  145. compactLevel: payload.level,
  146. compactStage: payload.stage,
  147. compactStageLabel: payload.stageLabel || payload.stage,
  148. compactMetadata: payload,
  149. });
  150. }
  151. default:
  152. return createNormalizedMessage({ ...base, kind: 'status', text: message.kind });
  153. }
  154. }
  155. export default router;