@djangocfg/ui-tools 2.1.335 → 2.1.336

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +68 -2
  2. package/dist/ChatRoot-IIYQEWUU.mjs +5 -0
  3. package/dist/ChatRoot-IIYQEWUU.mjs.map +1 -0
  4. package/dist/ChatRoot-PNNGQCYF.css +7 -0
  5. package/dist/ChatRoot-PNNGQCYF.css.map +1 -0
  6. package/dist/ChatRoot-UUKTYM4N.cjs +14 -0
  7. package/dist/ChatRoot-UUKTYM4N.cjs.map +1 -0
  8. package/dist/{CronScheduler.client-3O3VU4CI.mjs → CronScheduler.client-DLMXCPAJ.mjs} +4 -4
  9. package/dist/{CronScheduler.client-3O3VU4CI.mjs.map → CronScheduler.client-DLMXCPAJ.mjs.map} +1 -1
  10. package/dist/{CronScheduler.client-A4GO6YBY.cjs → CronScheduler.client-WEJF4PWQ.cjs} +14 -14
  11. package/dist/{CronScheduler.client-A4GO6YBY.cjs.map → CronScheduler.client-WEJF4PWQ.cjs.map} +1 -1
  12. package/dist/{DocsLayout-XLDB6CJ2.cjs → DocsLayout-N5ZJZPBY.cjs} +200 -199
  13. package/dist/DocsLayout-N5ZJZPBY.cjs.map +1 -0
  14. package/dist/{DocsLayout-CTJINVBM.mjs → DocsLayout-VFPPNKSQ.mjs} +7 -6
  15. package/dist/DocsLayout-VFPPNKSQ.mjs.map +1 -0
  16. package/dist/JsonSchemaForm-DD7CLRIG.cjs +13 -0
  17. package/dist/{JsonSchemaForm-6WMS4CIY.cjs.map → JsonSchemaForm-DD7CLRIG.cjs.map} +1 -1
  18. package/dist/JsonSchemaForm-XKUIVELK.mjs +4 -0
  19. package/dist/{JsonSchemaForm-KX4JT3M4.mjs.map → JsonSchemaForm-XKUIVELK.mjs.map} +1 -1
  20. package/dist/JsonTree-55625VVH.mjs +5 -0
  21. package/dist/{JsonTree-F27RMYSI.cjs.map → JsonTree-55625VVH.mjs.map} +1 -1
  22. package/dist/JsonTree-DCM5QGWF.cjs +11 -0
  23. package/dist/{JsonTree-QTJYSHCV.mjs.map → JsonTree-DCM5QGWF.cjs.map} +1 -1
  24. package/dist/{LottiePlayer.client-6WVWDO75.cjs → LottiePlayer.client-2S7ISJ2S.cjs} +6 -6
  25. package/dist/{LottiePlayer.client-6WVWDO75.cjs.map → LottiePlayer.client-2S7ISJ2S.cjs.map} +1 -1
  26. package/dist/{LottiePlayer.client-B4I6WNZM.mjs → LottiePlayer.client-5LDSSJWS.mjs} +4 -4
  27. package/dist/{LottiePlayer.client-B4I6WNZM.mjs.map → LottiePlayer.client-5LDSSJWS.mjs.map} +1 -1
  28. package/dist/{MapContainer-RYG4HPH4.cjs → MapContainer-76YL2JXL.cjs} +8 -8
  29. package/dist/{MapContainer-RYG4HPH4.cjs.map → MapContainer-76YL2JXL.cjs.map} +1 -1
  30. package/dist/{MapContainer-GXQLP5WY.mjs → MapContainer-7HXBI3OH.mjs} +3 -3
  31. package/dist/{MapContainer-GXQLP5WY.mjs.map → MapContainer-7HXBI3OH.mjs.map} +1 -1
  32. package/dist/{Mermaid.client-SXRRI2YW.mjs → Mermaid.client-NL4SVR7F.mjs} +4 -4
  33. package/dist/{Mermaid.client-SXRRI2YW.mjs.map → Mermaid.client-NL4SVR7F.mjs.map} +1 -1
  34. package/dist/{Mermaid.client-W76R5AKJ.cjs → Mermaid.client-NNTI6DFX.cjs} +26 -26
  35. package/dist/{Mermaid.client-W76R5AKJ.cjs.map → Mermaid.client-NNTI6DFX.cjs.map} +1 -1
  36. package/dist/Player-BRV7XTWR.mjs +4 -0
  37. package/dist/{Player-M3GC3VPE.mjs.map → Player-BRV7XTWR.mjs.map} +1 -1
  38. package/dist/Player-PM7F7DD7.cjs +13 -0
  39. package/dist/{Player-ZL2X5LGG.cjs.map → Player-PM7F7DD7.cjs.map} +1 -1
  40. package/dist/{PrettyCode.client-RPDIE5CH.cjs → PrettyCode.client-KOHDVPPN.cjs} +13 -13
  41. package/dist/{PrettyCode.client-RPDIE5CH.cjs.map → PrettyCode.client-KOHDVPPN.cjs.map} +1 -1
  42. package/dist/{PrettyCode.client-SPMTQEG4.mjs → PrettyCode.client-ZGYGKE7G.mjs} +4 -4
  43. package/dist/{PrettyCode.client-SPMTQEG4.mjs.map → PrettyCode.client-ZGYGKE7G.mjs.map} +1 -1
  44. package/dist/TreeRoot-N72OYKXU.cjs +19 -0
  45. package/dist/{TreeRoot-A3J65L6F.mjs.map → TreeRoot-N72OYKXU.cjs.map} +1 -1
  46. package/dist/TreeRoot-VGAIXCUA.mjs +4 -0
  47. package/dist/{TreeRoot-DSK5JILT.cjs.map → TreeRoot-VGAIXCUA.mjs.map} +1 -1
  48. package/dist/chunk-2ZLKZ5VR.mjs +631 -0
  49. package/dist/chunk-2ZLKZ5VR.mjs.map +1 -0
  50. package/dist/{chunk-LFWQ36LJ.mjs → chunk-5G5YBFS6.mjs} +4 -4
  51. package/dist/{chunk-LFWQ36LJ.mjs.map → chunk-5G5YBFS6.mjs.map} +1 -1
  52. package/dist/{chunk-IHAY6FO6.cjs → chunk-5I5QNGUG.cjs} +17 -17
  53. package/dist/{chunk-IHAY6FO6.cjs.map → chunk-5I5QNGUG.cjs.map} +1 -1
  54. package/dist/{chunk-F2CMIIOH.cjs → chunk-76NNDZH6.cjs} +42 -42
  55. package/dist/{chunk-F2CMIIOH.cjs.map → chunk-76NNDZH6.cjs.map} +1 -1
  56. package/dist/chunk-B5AWZOHJ.cjs +649 -0
  57. package/dist/chunk-B5AWZOHJ.cjs.map +1 -0
  58. package/dist/{chunk-KR6B3LVY.mjs → chunk-B6IR5KSC.mjs} +3 -3
  59. package/dist/{chunk-KR6B3LVY.mjs.map → chunk-B6IR5KSC.mjs.map} +1 -1
  60. package/dist/{chunk-5LBDYFWH.mjs → chunk-C6GXVH5J.mjs} +3 -3
  61. package/dist/{chunk-5LBDYFWH.mjs.map → chunk-C6GXVH5J.mjs.map} +1 -1
  62. package/dist/{chunk-NRKD4F5X.cjs → chunk-FEN5S772.cjs} +36 -36
  63. package/dist/{chunk-NRKD4F5X.cjs.map → chunk-FEN5S772.cjs.map} +1 -1
  64. package/dist/{chunk-2SMCH62O.cjs → chunk-FP2RLYQZ.cjs} +11 -11
  65. package/dist/{chunk-2SMCH62O.cjs.map → chunk-FP2RLYQZ.cjs.map} +1 -1
  66. package/dist/{chunk-MOME6KYD.mjs → chunk-G5IEC7SR.mjs} +3 -3
  67. package/dist/{chunk-MOME6KYD.mjs.map → chunk-G5IEC7SR.mjs.map} +1 -1
  68. package/dist/{chunk-SE5IERVH.mjs → chunk-GYIO7W7M.mjs} +3 -3
  69. package/dist/{chunk-SE5IERVH.mjs.map → chunk-GYIO7W7M.mjs.map} +1 -1
  70. package/dist/{chunk-3Z3A7FHA.cjs → chunk-IEEAENLX.cjs} +48 -48
  71. package/dist/{chunk-3Z3A7FHA.cjs.map → chunk-IEEAENLX.cjs.map} +1 -1
  72. package/dist/{chunk-DFTVB66S.cjs → chunk-KNDLV4PI.cjs} +85 -85
  73. package/dist/{chunk-DFTVB66S.cjs.map → chunk-KNDLV4PI.cjs.map} +1 -1
  74. package/dist/{chunk-SSUOENAZ.mjs → chunk-KNEQRUBA.mjs} +3 -3
  75. package/dist/{chunk-SSUOENAZ.mjs.map → chunk-KNEQRUBA.mjs.map} +1 -1
  76. package/dist/chunk-KRETIZU6.mjs +2218 -0
  77. package/dist/chunk-KRETIZU6.mjs.map +1 -0
  78. package/dist/{chunk-CGILA3WO.mjs → chunk-N2XQF2OL.mjs} +5 -3
  79. package/dist/{chunk-CGILA3WO.mjs.map → chunk-N2XQF2OL.mjs.map} +1 -1
  80. package/dist/{chunk-EUADAUBQ.mjs → chunk-N4MZYNR4.mjs} +4 -4
  81. package/dist/{chunk-EUADAUBQ.mjs.map → chunk-N4MZYNR4.mjs.map} +1 -1
  82. package/dist/chunk-NRXYYO5V.cjs +2257 -0
  83. package/dist/chunk-NRXYYO5V.cjs.map +1 -0
  84. package/dist/{chunk-GGKGH5PM.mjs → chunk-OBRSGM64.mjs} +4 -4
  85. package/dist/{chunk-GGKGH5PM.mjs.map → chunk-OBRSGM64.mjs.map} +1 -1
  86. package/dist/{chunk-6JTB2X72.mjs → chunk-ODO4GMW7.mjs} +3 -3
  87. package/dist/{chunk-6JTB2X72.mjs.map → chunk-ODO4GMW7.mjs.map} +1 -1
  88. package/dist/{chunk-WGEGR3DF.cjs → chunk-OLISEQHS.cjs} +5 -2
  89. package/dist/{chunk-WGEGR3DF.cjs.map → chunk-OLISEQHS.cjs.map} +1 -1
  90. package/dist/{chunk-PZKAH7WQ.mjs → chunk-PVAX67JG.mjs} +3 -3
  91. package/dist/{chunk-PZKAH7WQ.mjs.map → chunk-PVAX67JG.mjs.map} +1 -1
  92. package/dist/{chunk-PRPG2T2E.cjs → chunk-QJ6GTUCO.cjs} +6 -6
  93. package/dist/{chunk-PRPG2T2E.cjs.map → chunk-QJ6GTUCO.cjs.map} +1 -1
  94. package/dist/chunk-QW4RBGHN.cjs +961 -0
  95. package/dist/chunk-QW4RBGHN.cjs.map +1 -0
  96. package/dist/{chunk-33AMWFBZ.cjs → chunk-SGP7V2UW.cjs} +15 -15
  97. package/dist/{chunk-33AMWFBZ.cjs.map → chunk-SGP7V2UW.cjs.map} +1 -1
  98. package/dist/{chunk-FX2QFYWF.mjs → chunk-VWQ5WOIL.mjs} +3 -3
  99. package/dist/{chunk-FX2QFYWF.mjs.map → chunk-VWQ5WOIL.mjs.map} +1 -1
  100. package/dist/{chunk-ZLQHUZDU.cjs → chunk-YDPDTOSP.cjs} +139 -139
  101. package/dist/{chunk-ZLQHUZDU.cjs.map → chunk-YDPDTOSP.cjs.map} +1 -1
  102. package/dist/{chunk-77HQWEQ6.cjs → chunk-YW5IVWHQ.cjs} +33 -33
  103. package/dist/{chunk-77HQWEQ6.cjs.map → chunk-YW5IVWHQ.cjs.map} +1 -1
  104. package/dist/{chunk-YXBOAGIM.cjs → chunk-YXZ6GU7H.cjs} +7 -7
  105. package/dist/{chunk-YXBOAGIM.cjs.map → chunk-YXZ6GU7H.cjs.map} +1 -1
  106. package/dist/{chunk-62Y65TGK.mjs → chunk-ZUFTH5IR.mjs} +8 -631
  107. package/dist/chunk-ZUFTH5IR.mjs.map +1 -0
  108. package/dist/components-EHOGXATG.cjs +22 -0
  109. package/dist/{components-5UXYNAKR.cjs.map → components-EHOGXATG.cjs.map} +1 -1
  110. package/dist/components-MQ6DR7TX.cjs +26 -0
  111. package/dist/{components-CFXOEVPN.mjs.map → components-MQ6DR7TX.cjs.map} +1 -1
  112. package/dist/components-XRX7QGLB.mjs +5 -0
  113. package/dist/{components-WYEZL5TE.cjs.map → components-XRX7QGLB.mjs.map} +1 -1
  114. package/dist/components-YATKRWLH.mjs +5 -0
  115. package/dist/{components-ZAGG2PBO.mjs.map → components-YATKRWLH.mjs.map} +1 -1
  116. package/dist/file-icon/index.cjs +6 -6
  117. package/dist/file-icon/index.mjs +1 -1
  118. package/dist/index.cjs +735 -215
  119. package/dist/index.cjs.map +1 -1
  120. package/dist/index.d.cts +972 -39
  121. package/dist/index.d.ts +972 -39
  122. package/dist/index.mjs +387 -31
  123. package/dist/index.mjs.map +1 -1
  124. package/dist/tree/index.cjs +38 -38
  125. package/dist/tree/index.d.cts +2 -2
  126. package/dist/tree/index.d.ts +2 -2
  127. package/dist/tree/index.mjs +3 -3
  128. package/package.json +6 -6
  129. package/src/index.ts +5 -0
  130. package/src/stories/index.ts +3 -1
  131. package/src/tools/Chat/Chat.story.tsx +1006 -0
  132. package/src/tools/Chat/README.md +528 -0
  133. package/src/tools/Chat/components/Attachments.tsx +192 -0
  134. package/src/tools/Chat/components/ChatRoot.tsx +201 -0
  135. package/src/tools/Chat/components/Composer.tsx +134 -0
  136. package/src/tools/Chat/components/EmptyState.tsx +47 -0
  137. package/src/tools/Chat/components/ErrorBanner.tsx +47 -0
  138. package/src/tools/Chat/components/JumpToLatest.tsx +30 -0
  139. package/src/tools/Chat/components/MessageActions.tsx +72 -0
  140. package/src/tools/Chat/components/MessageBubble.tsx +228 -0
  141. package/src/tools/Chat/components/MessageList.tsx +82 -0
  142. package/src/tools/Chat/components/Sources.tsx +55 -0
  143. package/src/tools/Chat/components/StreamingIndicator.tsx +29 -0
  144. package/src/tools/Chat/components/ToolCalls.tsx +172 -0
  145. package/src/tools/Chat/components/index.ts +24 -0
  146. package/src/tools/Chat/config.ts +55 -0
  147. package/src/tools/Chat/context/ChatProvider.tsx +122 -0
  148. package/src/tools/Chat/context/index.ts +9 -0
  149. package/src/tools/Chat/core/audio/audioBus.ts +172 -0
  150. package/src/tools/Chat/core/audio/index.ts +8 -0
  151. package/src/tools/Chat/core/audio/preferences.ts +68 -0
  152. package/src/tools/Chat/core/audio/types.ts +49 -0
  153. package/src/tools/Chat/core/ids.ts +16 -0
  154. package/src/tools/Chat/core/index.ts +5 -0
  155. package/src/tools/Chat/core/markdown.ts +56 -0
  156. package/src/tools/Chat/core/payload-dispatch.ts +54 -0
  157. package/src/tools/Chat/core/persona.ts +35 -0
  158. package/src/tools/Chat/core/reducer.ts +335 -0
  159. package/src/tools/Chat/core/transport/http.ts +167 -0
  160. package/src/tools/Chat/core/transport/index.ts +13 -0
  161. package/src/tools/Chat/core/transport/mock.ts +134 -0
  162. package/src/tools/Chat/core/transport/sse.ts +116 -0
  163. package/src/tools/Chat/core/transport/types.ts +24 -0
  164. package/src/tools/Chat/hooks/index.ts +26 -0
  165. package/src/tools/Chat/hooks/useChat.ts +440 -0
  166. package/src/tools/Chat/hooks/useChatAudio.ts +191 -0
  167. package/src/tools/Chat/hooks/useChatComposer.ts +227 -0
  168. package/src/tools/Chat/hooks/useChatHistory.ts +59 -0
  169. package/src/tools/Chat/hooks/useChatLayout.ts +111 -0
  170. package/src/tools/Chat/hooks/useChatLightbox.ts +34 -0
  171. package/src/tools/Chat/hooks/useChatScroll.ts +132 -0
  172. package/src/tools/Chat/index.ts +158 -0
  173. package/src/tools/Chat/lazy.tsx +14 -0
  174. package/src/tools/Chat/types.ts +237 -0
  175. package/src/tools/Chat/utils/collectImageAttachments.ts +13 -0
  176. package/src/tools/Map/README.md +384 -0
  177. package/dist/DocsLayout-CTJINVBM.mjs.map +0 -1
  178. package/dist/DocsLayout-XLDB6CJ2.cjs.map +0 -1
  179. package/dist/JsonSchemaForm-6WMS4CIY.cjs +0 -13
  180. package/dist/JsonSchemaForm-KX4JT3M4.mjs +0 -4
  181. package/dist/JsonTree-F27RMYSI.cjs +0 -11
  182. package/dist/JsonTree-QTJYSHCV.mjs +0 -5
  183. package/dist/Player-M3GC3VPE.mjs +0 -4
  184. package/dist/Player-ZL2X5LGG.cjs +0 -13
  185. package/dist/TreeRoot-A3J65L6F.mjs +0 -4
  186. package/dist/TreeRoot-DSK5JILT.cjs +0 -19
  187. package/dist/chunk-62Y65TGK.mjs.map +0 -1
  188. package/dist/chunk-TKSFZHCG.cjs +0 -1597
  189. package/dist/chunk-TKSFZHCG.cjs.map +0 -1
  190. package/dist/components-5UXYNAKR.cjs +0 -22
  191. package/dist/components-CFXOEVPN.mjs +0 -5
  192. package/dist/components-WYEZL5TE.cjs +0 -26
  193. package/dist/components-ZAGG2PBO.mjs +0 -5
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Pure chat state machine. Zero React. Zero I/O.
3
+ *
4
+ * Invariants:
5
+ * - At most one message has `isStreaming === true` at any time. It is always
6
+ * the last message in the array.
7
+ * - Messages are immutable; updates produce new objects.
8
+ * - `version` bumps on edit so memo keys invalidate.
9
+ */
10
+
11
+ import type { ChatAttachment, ChatMessage, ChatSource, ChatToolCall } from '../types';
12
+
13
+ export interface ChatState {
14
+ sessionId: string | null;
15
+ messages: ChatMessage[];
16
+ /** Initial history load in flight. */
17
+ isLoading: boolean;
18
+ /** Assistant is generating a reply. */
19
+ isStreaming: boolean;
20
+ /** Older history page in flight. */
21
+ isLoadingMore: boolean;
22
+ hasMore: boolean;
23
+ oldestCursor: string | null;
24
+ error: string | null;
25
+ }
26
+
27
+ export const initialState: ChatState = {
28
+ sessionId: null,
29
+ messages: [],
30
+ isLoading: false,
31
+ isStreaming: false,
32
+ isLoadingMore: false,
33
+ hasMore: true,
34
+ oldestCursor: null,
35
+ error: null,
36
+ };
37
+
38
+ export type ChatAction =
39
+ | {
40
+ type: 'SESSION_SET';
41
+ sessionId: string;
42
+ messages?: ChatMessage[];
43
+ hasMore?: boolean;
44
+ cursor?: string | null;
45
+ }
46
+ | { type: 'HISTORY_LOAD_START' }
47
+ | {
48
+ type: 'HISTORY_LOAD_DONE';
49
+ messages: ChatMessage[];
50
+ hasMore: boolean;
51
+ cursor: string | null;
52
+ }
53
+ | { type: 'HISTORY_MORE_START' }
54
+ | {
55
+ type: 'HISTORY_MORE_DONE';
56
+ messages: ChatMessage[];
57
+ hasMore: boolean;
58
+ cursor: string | null;
59
+ }
60
+ | { type: 'MESSAGE_USER_ADD'; message: ChatMessage }
61
+ | { type: 'STREAM_START'; id: string; createdAt?: number }
62
+ | { type: 'STREAM_CHUNK'; delta: string }
63
+ | { type: 'STREAM_TOOL_ACTIVITY'; tool: string }
64
+ | { type: 'TOOL_CALL_START'; messageId: string; toolCall: ChatToolCall }
65
+ | { type: 'TOOL_CALL_DELTA'; messageId: string; toolId: string; delta: string }
66
+ | {
67
+ type: 'TOOL_CALL_END';
68
+ messageId: string;
69
+ toolId: string;
70
+ output: unknown;
71
+ status: 'success' | 'error';
72
+ }
73
+ | {
74
+ type: 'STREAM_DONE';
75
+ id: string;
76
+ tokensIn?: number;
77
+ tokensOut?: number;
78
+ sources?: ChatSource[];
79
+ }
80
+ | { type: 'STREAM_CANCELLED'; id: string; partialText: string; label?: string }
81
+ | { type: 'STREAM_ERROR'; id?: string; message: string }
82
+ | { type: 'MESSAGE_EDIT'; id: string; content: string }
83
+ | { type: 'MESSAGE_DELETE'; id: string }
84
+ | { type: 'MESSAGES_CLEAR' }
85
+ | { type: 'ERROR_SET'; error: string | null }
86
+ | {
87
+ type: 'ATTACHMENT_PROGRESS';
88
+ messageId: string;
89
+ attachmentId: string;
90
+ progress?: number;
91
+ status?: ChatAttachment['status'];
92
+ };
93
+
94
+ function updateLastStreaming(
95
+ messages: ChatMessage[],
96
+ patch: (m: ChatMessage) => ChatMessage,
97
+ ): ChatMessage[] {
98
+ const idx = findLastStreamingIndex(messages);
99
+ if (idx === -1) return messages;
100
+ const next = messages.slice();
101
+ next[idx] = patch(messages[idx]);
102
+ return next;
103
+ }
104
+
105
+ function findLastStreamingIndex(messages: ChatMessage[]): number {
106
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
107
+ if (messages[i].isStreaming) return i;
108
+ }
109
+ return -1;
110
+ }
111
+
112
+ function patchMessageById(
113
+ messages: ChatMessage[],
114
+ id: string,
115
+ patch: (m: ChatMessage) => ChatMessage,
116
+ ): ChatMessage[] {
117
+ const idx = messages.findIndex((m) => m.id === id);
118
+ if (idx === -1) return messages;
119
+ const next = messages.slice();
120
+ next[idx] = patch(messages[idx]);
121
+ return next;
122
+ }
123
+
124
+ export function reducer(state: ChatState, action: ChatAction): ChatState {
125
+ switch (action.type) {
126
+ case 'SESSION_SET':
127
+ return {
128
+ ...state,
129
+ sessionId: action.sessionId,
130
+ messages: action.messages ?? state.messages,
131
+ hasMore: action.hasMore ?? state.hasMore,
132
+ oldestCursor: action.cursor ?? state.oldestCursor,
133
+ error: null,
134
+ };
135
+
136
+ case 'HISTORY_LOAD_START':
137
+ return { ...state, isLoading: true, error: null };
138
+
139
+ case 'HISTORY_LOAD_DONE':
140
+ return {
141
+ ...state,
142
+ isLoading: false,
143
+ messages: action.messages,
144
+ hasMore: action.hasMore,
145
+ oldestCursor: action.cursor,
146
+ };
147
+
148
+ case 'HISTORY_MORE_START':
149
+ return { ...state, isLoadingMore: true };
150
+
151
+ case 'HISTORY_MORE_DONE':
152
+ return {
153
+ ...state,
154
+ isLoadingMore: false,
155
+ // Older messages prepend to the top.
156
+ messages: [...action.messages, ...state.messages],
157
+ hasMore: action.hasMore,
158
+ oldestCursor: action.cursor,
159
+ };
160
+
161
+ case 'MESSAGE_USER_ADD':
162
+ return {
163
+ ...state,
164
+ messages: [...state.messages, action.message],
165
+ error: null,
166
+ };
167
+
168
+ case 'STREAM_START': {
169
+ if (state.isStreaming) {
170
+ if (typeof console !== 'undefined') {
171
+ // Soft guard — UI should disable the composer; this catches bugs.
172
+ // eslint-disable-next-line no-console
173
+ console.warn('[chat] STREAM_START while already streaming, ignoring');
174
+ }
175
+ return state;
176
+ }
177
+ const placeholder: ChatMessage = {
178
+ id: action.id,
179
+ role: 'assistant',
180
+ content: '',
181
+ createdAt: action.createdAt ?? Date.now(),
182
+ isStreaming: true,
183
+ };
184
+ return {
185
+ ...state,
186
+ isStreaming: true,
187
+ messages: [...state.messages, placeholder],
188
+ };
189
+ }
190
+
191
+ case 'STREAM_CHUNK': {
192
+ const messages = updateLastStreaming(state.messages, (m) => ({
193
+ ...m,
194
+ content: m.content + action.delta,
195
+ }));
196
+ return { ...state, messages };
197
+ }
198
+
199
+ case 'STREAM_TOOL_ACTIVITY': {
200
+ const messages = updateLastStreaming(state.messages, (m) => ({
201
+ ...m,
202
+ toolActivity: action.tool,
203
+ }));
204
+ return { ...state, messages };
205
+ }
206
+
207
+ case 'TOOL_CALL_START': {
208
+ const messages = patchMessageById(state.messages, action.messageId, (m) => ({
209
+ ...m,
210
+ toolCalls: [...(m.toolCalls ?? []), action.toolCall],
211
+ }));
212
+ return { ...state, messages };
213
+ }
214
+
215
+ case 'TOOL_CALL_DELTA': {
216
+ const messages = patchMessageById(state.messages, action.messageId, (m) => {
217
+ if (!m.toolCalls) return m;
218
+ return {
219
+ ...m,
220
+ toolCalls: m.toolCalls.map((tc) =>
221
+ tc.id === action.toolId
222
+ ? { ...tc, streamingText: (tc.streamingText ?? '') + action.delta }
223
+ : tc,
224
+ ),
225
+ };
226
+ });
227
+ return { ...state, messages };
228
+ }
229
+
230
+ case 'TOOL_CALL_END': {
231
+ const messages = patchMessageById(state.messages, action.messageId, (m) => {
232
+ if (!m.toolCalls) return m;
233
+ return {
234
+ ...m,
235
+ toolCalls: m.toolCalls.map((tc) =>
236
+ tc.id === action.toolId
237
+ ? {
238
+ ...tc,
239
+ output: action.output,
240
+ status: action.status,
241
+ streamingText: undefined,
242
+ endedAt: Date.now(),
243
+ }
244
+ : tc,
245
+ ),
246
+ };
247
+ });
248
+ return { ...state, messages };
249
+ }
250
+
251
+ case 'STREAM_DONE': {
252
+ const messages = patchMessageById(state.messages, action.id, (m) => ({
253
+ ...m,
254
+ isStreaming: false,
255
+ tokensIn: action.tokensIn ?? m.tokensIn,
256
+ tokensOut: action.tokensOut ?? m.tokensOut,
257
+ sources: action.sources ?? m.sources,
258
+ }));
259
+ return { ...state, isStreaming: false, messages };
260
+ }
261
+
262
+ case 'STREAM_CANCELLED': {
263
+ const suffix = action.label ?? '[cancelled]';
264
+ const messages = patchMessageById(state.messages, action.id, (m) => ({
265
+ ...m,
266
+ isStreaming: false,
267
+ content: action.partialText + (action.partialText ? `\n\n_${suffix}_` : `_${suffix}_`),
268
+ }));
269
+ return { ...state, isStreaming: false, messages };
270
+ }
271
+
272
+ case 'STREAM_ERROR': {
273
+ const messages = action.id
274
+ ? patchMessageById(state.messages, action.id, (m) => ({
275
+ ...m,
276
+ isStreaming: false,
277
+ isError: true,
278
+ }))
279
+ : state.messages;
280
+ return { ...state, isStreaming: false, error: action.message, messages };
281
+ }
282
+
283
+ case 'MESSAGE_EDIT': {
284
+ const messages = patchMessageById(state.messages, action.id, (m) => ({
285
+ ...m,
286
+ content: action.content,
287
+ version: (m.version ?? 0) + 1,
288
+ }));
289
+ return { ...state, messages };
290
+ }
291
+
292
+ case 'MESSAGE_DELETE':
293
+ return {
294
+ ...state,
295
+ messages: state.messages.filter((m) => m.id !== action.id),
296
+ };
297
+
298
+ case 'MESSAGES_CLEAR':
299
+ return {
300
+ ...state,
301
+ messages: [],
302
+ isStreaming: false,
303
+ error: null,
304
+ };
305
+
306
+ case 'ERROR_SET':
307
+ return { ...state, error: action.error };
308
+
309
+ case 'ATTACHMENT_PROGRESS': {
310
+ const messages = patchMessageById(state.messages, action.messageId, (m) => {
311
+ if (!m.attachments) return m;
312
+ return {
313
+ ...m,
314
+ attachments: m.attachments.map((a) =>
315
+ a.id === action.attachmentId
316
+ ? {
317
+ ...a,
318
+ progress: action.progress ?? a.progress,
319
+ status: action.status ?? a.status,
320
+ }
321
+ : a,
322
+ ),
323
+ };
324
+ });
325
+ return { ...state, messages };
326
+ }
327
+
328
+ default: {
329
+ // Exhaustiveness check.
330
+ const _exhaustive: never = action;
331
+ void _exhaustive;
332
+ return state;
333
+ }
334
+ }
335
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * HTTP + SSE transport. Default implementation for web hosts.
3
+ *
4
+ * Backend contract (see @dev/@refactoring7-chat/06-integration.md):
5
+ * POST /sessions → SessionInfo (JSON)
6
+ * GET /sessions/:id/history?cursor= → HistoryPage (JSON)
7
+ * POST /sessions/:id/messages → SSE stream of ChatStreamEvent
8
+ * POST /sessions/:id/messages/buffered → ChatMessage (JSON, fallback)
9
+ * DELETE /sessions/:id → 204
10
+ */
11
+
12
+ import type {
13
+ ChatMessage,
14
+ ChatStreamEvent,
15
+ ChatTransport,
16
+ CreateSessionOptions,
17
+ HistoryPage,
18
+ SendOptions,
19
+ SessionInfo,
20
+ StreamOptions,
21
+ } from '../../types';
22
+ import { TransportError } from './types';
23
+ import { parseSSE } from './sse';
24
+
25
+ export interface HttpTransportConfig {
26
+ /** Base URL without trailing slash, e.g. '/api/chat' or 'https://api.example.com/v1/chat'. */
27
+ baseUrl: string;
28
+ /** Optional slug appended/forwarded as project identifier. */
29
+ slug?: string;
30
+ /** Returns headers applied to every request — e.g. Authorization. */
31
+ getAuthHeader?: () => Record<string, string> | Promise<Record<string, string>>;
32
+ /** Default fetch timeout (per non-streaming request). */
33
+ timeoutMs?: number;
34
+ /** Override fetch implementation (useful for tests or custom retry layers). */
35
+ fetchImpl?: typeof fetch;
36
+ }
37
+
38
+ const DEFAULT_TIMEOUT = 20_000;
39
+
40
+ async function jsonOrThrow<T>(res: Response, label: string): Promise<T> {
41
+ if (!res.ok) {
42
+ const text = await res.text().catch(() => '');
43
+ throw new TransportError(
44
+ `${label} failed (${res.status}): ${text || res.statusText}`,
45
+ mapStatusToCode(res.status),
46
+ );
47
+ }
48
+ try {
49
+ return (await res.json()) as T;
50
+ } catch {
51
+ throw new TransportError(`${label} returned invalid JSON`, 'invalid_response');
52
+ }
53
+ }
54
+
55
+ function mapStatusToCode(status: number): string {
56
+ if (status === 401) return 'unauthorized';
57
+ if (status === 403) return 'forbidden';
58
+ if (status === 404) return 'not_found';
59
+ if (status === 429) return 'rate_limited';
60
+ if (status >= 500) return 'server_error';
61
+ return 'http_error';
62
+ }
63
+
64
+ function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
65
+ const ctrl = new AbortController();
66
+ const onAbort = () => ctrl.abort();
67
+ signal?.addEventListener('abort', onAbort, { once: true });
68
+ setTimeout(() => ctrl.abort(), timeoutMs);
69
+ return ctrl.signal;
70
+ }
71
+
72
+ export function createHttpTransport(config: HttpTransportConfig): ChatTransport {
73
+ const fetchImpl = config.fetchImpl ?? fetch;
74
+ const timeout = config.timeoutMs ?? DEFAULT_TIMEOUT;
75
+ const base = config.baseUrl.replace(/\/$/, '');
76
+
77
+ async function buildHeaders(extra?: Record<string, string>): Promise<Record<string, string>> {
78
+ const auth = (await config.getAuthHeader?.()) ?? {};
79
+ return {
80
+ 'Content-Type': 'application/json',
81
+ ...auth,
82
+ ...(extra ?? {}),
83
+ };
84
+ }
85
+
86
+ return {
87
+ async createSession(opts?: CreateSessionOptions): Promise<SessionInfo> {
88
+ const res = await fetchImpl(`${base}/sessions`, {
89
+ method: 'POST',
90
+ headers: await buildHeaders(),
91
+ body: JSON.stringify({ slug: config.slug, metadata: opts?.metadata ?? {} }),
92
+ signal: withTimeout(undefined, timeout),
93
+ });
94
+ return jsonOrThrow<SessionInfo>(res, 'createSession');
95
+ },
96
+
97
+ async loadHistory(sessionId, cursor, limit): Promise<HistoryPage> {
98
+ const params = new URLSearchParams();
99
+ if (cursor) params.set('cursor', cursor);
100
+ if (limit) params.set('limit', String(limit));
101
+ const url = `${base}/sessions/${encodeURIComponent(sessionId)}/history${
102
+ params.toString() ? `?${params.toString()}` : ''
103
+ }`;
104
+ const res = await fetchImpl(url, {
105
+ method: 'GET',
106
+ headers: await buildHeaders(),
107
+ signal: withTimeout(undefined, timeout),
108
+ });
109
+ return jsonOrThrow<HistoryPage>(res, 'loadHistory');
110
+ },
111
+
112
+ async *stream(
113
+ sessionId: string,
114
+ content: string,
115
+ options: StreamOptions,
116
+ ): AsyncGenerator<ChatStreamEvent, void, void> {
117
+ const url = `${base}/sessions/${encodeURIComponent(sessionId)}/messages`;
118
+ const res = await fetchImpl(url, {
119
+ method: 'POST',
120
+ headers: await buildHeaders({ Accept: 'text/event-stream' }),
121
+ body: JSON.stringify({
122
+ content,
123
+ attachments: options.attachments ?? [],
124
+ metadata: options.metadata ?? {},
125
+ }),
126
+ signal: options.signal,
127
+ });
128
+
129
+ if (!res.ok) {
130
+ const text = await res.text().catch(() => '');
131
+ throw new TransportError(
132
+ `stream failed (${res.status}): ${text || res.statusText}`,
133
+ mapStatusToCode(res.status),
134
+ );
135
+ }
136
+
137
+ yield* parseSSE(res, { signal: options.signal });
138
+ },
139
+
140
+ async send(sessionId, content, options?: SendOptions): Promise<ChatMessage> {
141
+ const url = `${base}/sessions/${encodeURIComponent(sessionId)}/messages/buffered`;
142
+ const res = await fetchImpl(url, {
143
+ method: 'POST',
144
+ headers: await buildHeaders(),
145
+ body: JSON.stringify({
146
+ content,
147
+ attachments: options?.attachments ?? [],
148
+ metadata: options?.metadata ?? {},
149
+ }),
150
+ signal: options?.signal ?? withTimeout(undefined, timeout),
151
+ });
152
+ return jsonOrThrow<ChatMessage>(res, 'send');
153
+ },
154
+
155
+ async closeSession(sessionId: string): Promise<void> {
156
+ const url = `${base}/sessions/${encodeURIComponent(sessionId)}`;
157
+ const res = await fetchImpl(url, {
158
+ method: 'DELETE',
159
+ headers: await buildHeaders(),
160
+ signal: withTimeout(undefined, timeout),
161
+ });
162
+ if (!res.ok && res.status !== 404) {
163
+ throw new TransportError(`closeSession failed (${res.status})`, mapStatusToCode(res.status));
164
+ }
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,13 @@
1
+ export { createHttpTransport, type HttpTransportConfig } from './http';
2
+ export { createMockTransport, type MockTransportOptions } from './mock';
3
+ export { parseSSE, type ParseSSEOptions } from './sse';
4
+ export { TransportError } from './types';
5
+ export type {
6
+ ChatTransport,
7
+ ChatStreamEvent,
8
+ CreateSessionOptions,
9
+ SessionInfo,
10
+ HistoryPage,
11
+ StreamOptions,
12
+ SendOptions,
13
+ } from './types';
@@ -0,0 +1,134 @@
1
+ /**
2
+ * In-memory chat transport for stories and tests. Replays scripted replies.
3
+ */
4
+
5
+ import type {
6
+ ChatMessage,
7
+ ChatStreamEvent,
8
+ ChatTransport,
9
+ CreateSessionOptions,
10
+ HistoryPage,
11
+ SendOptions,
12
+ SessionInfo,
13
+ StreamOptions,
14
+ } from '../../types';
15
+ import { createId } from '../ids';
16
+
17
+ export interface MockTransportOptions {
18
+ /** Each entry is the assistant's reply for one user turn. Strings are split
19
+ * into chunks; arrays are taken as the exact event sequence (after a
20
+ * prepended `message_start` and before a synthetic `message_end`). */
21
+ replies?: Array<string | ChatStreamEvent[]>;
22
+ latencyMs?: number;
23
+ /** Initial history returned by `createSession`. */
24
+ initialMessages?: ChatMessage[];
25
+ shouldFail?: (attempt: number) => boolean;
26
+ }
27
+
28
+ const DEFAULT_REPLY = 'Hi there!';
29
+
30
+ function splitForStream(text: string, parts = 4): string[] {
31
+ if (!text) return [];
32
+ const chunkSize = Math.max(1, Math.ceil(text.length / parts));
33
+ const out: string[] = [];
34
+ for (let i = 0; i < text.length; i += chunkSize) {
35
+ out.push(text.slice(i, i + chunkSize));
36
+ }
37
+ return out;
38
+ }
39
+
40
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
41
+
42
+ export function createMockTransport(opts: MockTransportOptions = {}): ChatTransport {
43
+ const replies = opts.replies?.length ? opts.replies : [DEFAULT_REPLY];
44
+ const latency = opts.latencyMs ?? 30;
45
+ const history: ChatMessage[] = [...(opts.initialMessages ?? [])];
46
+ let turn = 0;
47
+ let attempt = 0;
48
+
49
+ return {
50
+ async createSession(_opts?: CreateSessionOptions): Promise<SessionInfo> {
51
+ await sleep(latency);
52
+ return {
53
+ sessionId: createId('s'),
54
+ messages: history.length ? [...history] : undefined,
55
+ hasMore: false,
56
+ cursor: null,
57
+ resumed: history.length > 0,
58
+ };
59
+ },
60
+
61
+ async loadHistory(_sid, _cursor, _limit): Promise<HistoryPage> {
62
+ await sleep(latency);
63
+ return { messages: [], hasMore: false, nextCursor: null };
64
+ },
65
+
66
+ async *stream(
67
+ _sid: string,
68
+ content: string,
69
+ options: StreamOptions,
70
+ ): AsyncGenerator<ChatStreamEvent, void, void> {
71
+ attempt += 1;
72
+ if (opts.shouldFail?.(attempt)) {
73
+ throw new Error('mock transport scripted failure');
74
+ }
75
+
76
+ // Record the user message for any subsequent loadHistory.
77
+ history.push({
78
+ id: createId('u'),
79
+ role: 'user',
80
+ content,
81
+ createdAt: Date.now(),
82
+ });
83
+
84
+ const messageId = createId('a');
85
+ yield { type: 'message_start', messageId, sessionId: _sid };
86
+
87
+ const reply = replies[turn % replies.length];
88
+ turn += 1;
89
+
90
+ if (typeof reply === 'string') {
91
+ for (const piece of splitForStream(reply)) {
92
+ if (options.signal.aborted) return;
93
+ await sleep(latency);
94
+ yield { type: 'chunk', delta: piece };
95
+ }
96
+ yield { type: 'message_end', tokensIn: content.length, tokensOut: reply.length };
97
+ } else {
98
+ for (const ev of reply) {
99
+ if (options.signal.aborted) return;
100
+ await sleep(latency);
101
+ yield ev;
102
+ }
103
+ // If the script didn't end the message itself, do it for them.
104
+ const lastType = reply[reply.length - 1]?.type;
105
+ if (lastType !== 'message_end' && lastType !== 'error') {
106
+ yield { type: 'message_end' };
107
+ }
108
+ }
109
+ },
110
+
111
+ async send(_sid, content, _options?: SendOptions): Promise<ChatMessage> {
112
+ await sleep(latency);
113
+ const reply = replies[turn % replies.length];
114
+ turn += 1;
115
+ const text =
116
+ typeof reply === 'string'
117
+ ? reply
118
+ : reply
119
+ .filter((e) => e.type === 'chunk')
120
+ .map((e) => (e as { delta: string }).delta)
121
+ .join('');
122
+ return {
123
+ id: createId('a'),
124
+ role: 'assistant',
125
+ content: text || DEFAULT_REPLY,
126
+ createdAt: Date.now(),
127
+ };
128
+ },
129
+
130
+ async closeSession(_sid: string): Promise<void> {
131
+ // no-op
132
+ },
133
+ };
134
+ }