@superxd-studio/adptr 0.5.0

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 (221) hide show
  1. package/GENERATIVE.md +101 -0
  2. package/INTERACTIONS.md +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +57 -0
  5. package/REUSE.md +170 -0
  6. package/REVIEW.md +68 -0
  7. package/THIRD-PARTY-NOTICES.md +28 -0
  8. package/ai-sdk/index.js +96 -0
  9. package/assets/ibm-plex-mono-cyrillic-400-normal.woff2 +0 -0
  10. package/assets/ibm-plex-mono-cyrillic-500-normal.woff2 +0 -0
  11. package/assets/ibm-plex-mono-cyrillic-ext-400-normal.woff2 +0 -0
  12. package/assets/ibm-plex-mono-cyrillic-ext-500-normal.woff2 +0 -0
  13. package/assets/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  14. package/assets/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  15. package/assets/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  16. package/assets/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  17. package/assets/ibm-plex-mono-vietnamese-400-normal.woff2 +0 -0
  18. package/assets/ibm-plex-mono-vietnamese-500-normal.woff2 +0 -0
  19. package/assets/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  20. package/assets/instrument-sans-latin-wght-normal.woff2 +0 -0
  21. package/assistant-ui/index.js +139 -0
  22. package/chunks/callout-DKMHSnjc.js +342 -0
  23. package/chunks/canvas-BJ15-XxE.js +598 -0
  24. package/chunks/controller-Cfpg0C7v.js +180 -0
  25. package/chunks/core-BCqVRLUy.js +312 -0
  26. package/chunks/text-field-BLtoaZFn.js +41 -0
  27. package/chunks/types-BGF7r1my.js +115 -0
  28. package/chunks/ui-9DyNsYXY.js +967 -0
  29. package/core/index.js +4 -0
  30. package/custom/index.js +64 -0
  31. package/examples/ai-sdk-backend/package.json +11 -0
  32. package/examples/ai-sdk-backend/server.mjs +316 -0
  33. package/examples/custom-backend/copy.mjs +7 -0
  34. package/examples/custom-backend/server.mjs +118 -0
  35. package/examples/generative/specs.json +2094 -0
  36. package/examples/review-backend/copy.mjs +27 -0
  37. package/examples/review-backend/package.json +11 -0
  38. package/examples/review-backend/server.mjs +276 -0
  39. package/examples/web-ai-sdk/index.html +12 -0
  40. package/examples/web-ai-sdk/package.json +21 -0
  41. package/examples/web-ai-sdk/src/copy.ts +1 -0
  42. package/examples/web-ai-sdk/src/main.tsx +64 -0
  43. package/examples/web-ai-sdk/tsconfig.json +13 -0
  44. package/examples/web-generative/index.html +12 -0
  45. package/examples/web-generative/package.json +23 -0
  46. package/examples/web-generative/src/copy.ts +42 -0
  47. package/examples/web-generative/src/main.tsx +74 -0
  48. package/examples/web-generative/tsconfig.json +14 -0
  49. package/examples/web-independent/index.html +12 -0
  50. package/examples/web-independent/package.json +20 -0
  51. package/examples/web-independent/src/main.tsx +32 -0
  52. package/examples/web-independent/tsconfig.json +14 -0
  53. package/examples/web-interactions/index.html +12 -0
  54. package/examples/web-interactions/package.json +21 -0
  55. package/examples/web-interactions/src/copy.ts +215 -0
  56. package/examples/web-interactions/src/examples.tsx +628 -0
  57. package/examples/web-interactions/src/main.tsx +116 -0
  58. package/examples/web-interactions/tsconfig.json +14 -0
  59. package/examples/web-review/index.html +12 -0
  60. package/examples/web-review/package.json +20 -0
  61. package/examples/web-review/src/main.tsx +59 -0
  62. package/examples/web-review/tsconfig.json +14 -0
  63. package/fonts.css +117 -0
  64. package/generative/index.js +1560 -0
  65. package/interactions/index.js +1591 -0
  66. package/langgraph-review/index.js +36 -0
  67. package/licenses/ai-sdk.txt +13 -0
  68. package/licenses/assistant-ui-generative.txt +21 -0
  69. package/licenses/assistant-ui.txt +23 -0
  70. package/licenses/ibm-plex-mono.txt +93 -0
  71. package/licenses/instrument-sans.txt +93 -0
  72. package/licenses/internationalized-date.txt +201 -0
  73. package/licenses/langgraph.txt +21 -0
  74. package/licenses/lucide.txt +43 -0
  75. package/licenses/react-markdown.txt +21 -0
  76. package/licenses/remark-gfm.txt +22 -0
  77. package/licenses/zod.txt +21 -0
  78. package/package.json +138 -0
  79. package/review/index.js +31 -0
  80. package/source/components/avatar.tsx +50 -0
  81. package/source/components/button.tsx +155 -0
  82. package/source/components/callout.tsx +65 -0
  83. package/source/components/canvas/connector.tsx +14 -0
  84. package/source/components/canvas/copy.ts +34 -0
  85. package/source/components/canvas/index.tsx +257 -0
  86. package/source/components/canvas/recorder.ts +36 -0
  87. package/source/components/date-time-field.tsx +77 -0
  88. package/source/components/flow-motion.tsx +109 -0
  89. package/source/components/icons.tsx +275 -0
  90. package/source/components/responsive-overlay.tsx +91 -0
  91. package/source/components/scenario-picker/copy.ts +5 -0
  92. package/source/components/scenario-picker/index.tsx +142 -0
  93. package/source/components/scroll-area.tsx +59 -0
  94. package/source/components/search-picker.tsx +127 -0
  95. package/source/components/segmented-control.tsx +123 -0
  96. package/source/components/skeleton.tsx +11 -0
  97. package/source/components/spinner.tsx +19 -0
  98. package/source/components/superxd-arrow.tsx +22 -0
  99. package/source/components/switch.tsx +45 -0
  100. package/source/components/text-field.tsx +81 -0
  101. package/source/components/thinking.tsx +16 -0
  102. package/source/components/ui/copy.ts +12 -0
  103. package/source/components/ui/display.tsx +276 -0
  104. package/source/components/ui/navigation.tsx +346 -0
  105. package/source/components/ui/portal-theme.tsx +28 -0
  106. package/source/components/ui/selection.tsx +336 -0
  107. package/source/lib/cn.ts +23 -0
  108. package/source/reusable/ai-sdk/index.ts +114 -0
  109. package/source/reusable/assistant-ui/index.tsx +266 -0
  110. package/source/reusable/core/attachments.ts +163 -0
  111. package/source/reusable/core/index.ts +417 -0
  112. package/source/reusable/custom/index.ts +90 -0
  113. package/source/reusable/generative/actions.ts +188 -0
  114. package/source/reusable/generative/copy.ts +47 -0
  115. package/source/reusable/generative/data.tsx +126 -0
  116. package/source/reusable/generative/index.tsx +517 -0
  117. package/source/reusable/generative/schema.ts +327 -0
  118. package/source/reusable/generative/timeline.tsx +214 -0
  119. package/source/reusable/generative/vocabulary.tsx +847 -0
  120. package/source/reusable/interactions/agent.ts +259 -0
  121. package/source/reusable/interactions/artifact.ts +236 -0
  122. package/source/reusable/interactions/context.tsx +38 -0
  123. package/source/reusable/interactions/copy.ts +117 -0
  124. package/source/reusable/interactions/feedback.ts +73 -0
  125. package/source/reusable/interactions/index.ts +11 -0
  126. package/source/reusable/interactions/presentation.tsx +483 -0
  127. package/source/reusable/interactions/store.ts +74 -0
  128. package/source/reusable/interactions/threads.ts +126 -0
  129. package/source/reusable/interactions/web.tsx +574 -0
  130. package/source/reusable/langgraph-review/index.ts +73 -0
  131. package/source/reusable/review/controller.ts +263 -0
  132. package/source/reusable/review/index.ts +47 -0
  133. package/source/reusable/review/types.ts +189 -0
  134. package/source/reusable/ui/index.ts +25 -0
  135. package/source/reusable/uploads/index.ts +48 -0
  136. package/source/reusable/web/attachments/copy.ts +23 -0
  137. package/source/reusable/web/attachments/index.tsx +233 -0
  138. package/source/reusable/web/copy.ts +22 -0
  139. package/source/reusable/web/index.tsx +470 -0
  140. package/source/reusable/web/message/copy.ts +26 -0
  141. package/source/reusable/web/message/index.tsx +388 -0
  142. package/source/reusable/web/message/streaming.ts +64 -0
  143. package/source/reusable/web/message/styles.css +21 -0
  144. package/source/reusable/web/review/copy.ts +78 -0
  145. package/source/reusable/web/review/flow.ts +24 -0
  146. package/source/reusable/web/review/index.tsx +506 -0
  147. package/source/reusable/web/styles.ts +2 -0
  148. package/source/styles/library.css +612 -0
  149. package/source/styles/theme.css +228 -0
  150. package/styles.css +3 -0
  151. package/styles.d.ts +1 -0
  152. package/types/components/avatar.d.ts +14 -0
  153. package/types/components/button.d.ts +36 -0
  154. package/types/components/callout.d.ts +19 -0
  155. package/types/components/canvas/connector.d.ts +2 -0
  156. package/types/components/canvas/copy.d.ts +34 -0
  157. package/types/components/canvas/index.d.ts +33 -0
  158. package/types/components/canvas/recorder.d.ts +10 -0
  159. package/types/components/date-time-field.d.ts +11 -0
  160. package/types/components/flow-motion.d.ts +38 -0
  161. package/types/components/icons.d.ts +138 -0
  162. package/types/components/responsive-overlay.d.ts +15 -0
  163. package/types/components/scenario-picker/copy.d.ts +5 -0
  164. package/types/components/scenario-picker/index.d.ts +14 -0
  165. package/types/components/scroll-area.d.ts +14 -0
  166. package/types/components/search-picker.d.ts +27 -0
  167. package/types/components/segmented-control.d.ts +23 -0
  168. package/types/components/skeleton.d.ts +4 -0
  169. package/types/components/spinner.d.ts +4 -0
  170. package/types/components/superxd-arrow.d.ts +5 -0
  171. package/types/components/switch.d.ts +8 -0
  172. package/types/components/text-field.d.ts +19 -0
  173. package/types/components/thinking.d.ts +8 -0
  174. package/types/components/ui/copy.d.ts +12 -0
  175. package/types/components/ui/display.d.ts +50 -0
  176. package/types/components/ui/navigation.d.ts +63 -0
  177. package/types/components/ui/portal-theme.d.ts +8 -0
  178. package/types/components/ui/selection.d.ts +37 -0
  179. package/types/lib/cn.d.ts +3 -0
  180. package/types/reusable/ai-sdk/index.d.ts +15 -0
  181. package/types/reusable/assistant-ui/index.d.ts +12 -0
  182. package/types/reusable/core/attachments.d.ts +54 -0
  183. package/types/reusable/core/index.d.ts +107 -0
  184. package/types/reusable/custom/index.d.ts +14 -0
  185. package/types/reusable/generative/actions.d.ts +45 -0
  186. package/types/reusable/generative/copy.d.ts +48 -0
  187. package/types/reusable/generative/data.d.ts +4 -0
  188. package/types/reusable/generative/index.d.ts +30 -0
  189. package/types/reusable/generative/schema.d.ts +303 -0
  190. package/types/reusable/generative/timeline.d.ts +21 -0
  191. package/types/reusable/generative/vocabulary.d.ts +34 -0
  192. package/types/reusable/interactions/agent.d.ts +98 -0
  193. package/types/reusable/interactions/artifact.d.ts +118 -0
  194. package/types/reusable/interactions/context.d.ts +130 -0
  195. package/types/reusable/interactions/copy.d.ts +115 -0
  196. package/types/reusable/interactions/feedback.d.ts +34 -0
  197. package/types/reusable/interactions/index.d.ts +10 -0
  198. package/types/reusable/interactions/presentation.d.ts +97 -0
  199. package/types/reusable/interactions/store.d.ts +29 -0
  200. package/types/reusable/interactions/threads.d.ts +38 -0
  201. package/types/reusable/interactions/web.d.ts +22 -0
  202. package/types/reusable/langgraph-review/index.d.ts +5 -0
  203. package/types/reusable/review/controller.d.ts +52 -0
  204. package/types/reusable/review/index.d.ts +12 -0
  205. package/types/reusable/review/types.d.ts +61 -0
  206. package/types/reusable/ui/index.d.ts +19 -0
  207. package/types/reusable/uploads/index.d.ts +11 -0
  208. package/types/reusable/web/attachments/copy.d.ts +25 -0
  209. package/types/reusable/web/attachments/index.d.ts +24 -0
  210. package/types/reusable/web/copy.d.ts +24 -0
  211. package/types/reusable/web/index.d.ts +41 -0
  212. package/types/reusable/web/message/copy.d.ts +27 -0
  213. package/types/reusable/web/message/index.d.ts +56 -0
  214. package/types/reusable/web/message/streaming.d.ts +27 -0
  215. package/types/reusable/web/review/copy.d.ts +72 -0
  216. package/types/reusable/web/review/flow.d.ts +3 -0
  217. package/types/reusable/web/review/index.d.ts +38 -0
  218. package/types/reusable/web/styles.d.ts +2 -0
  219. package/ui/index.js +4 -0
  220. package/uploads/index.js +34 -0
  221. package/web/index.js +931 -0
@@ -0,0 +1,266 @@
1
+ import { useMemo, type ReactNode } from "react";
2
+ import {
3
+ AssistantRuntimeProvider,
4
+ useLocalRuntime,
5
+ useAui,
6
+ useAuiState,
7
+ type ChatModelAdapter,
8
+ } from "@assistant-ui/react";
9
+ import {
10
+ ChatInterrupted,
11
+ type ChatActions,
12
+ type ChatAdapter,
13
+ type ChatSnapshot,
14
+ parseChatFile,
15
+ } from "../core/index.js";
16
+
17
+ /** Optional bridge. assistant-ui alone owns state; there is no mirrored controller. */
18
+ type Projection = { snapshot: ChatSnapshot; actions: ChatActions };
19
+ export function AssistantChat({
20
+ adapter,
21
+ children,
22
+ }: {
23
+ adapter: ChatAdapter;
24
+ children: (chat: Projection) => ReactNode;
25
+ }) {
26
+ const bridge = useMemo<ChatModelAdapter>(
27
+ () => ({
28
+ async *run({ messages, abortSignal }) {
29
+ let text = "";
30
+ let metadata: Readonly<Record<string, unknown>> = {};
31
+ try {
32
+ for await (const event of adapter.run(
33
+ {
34
+ requestId: globalThis.crypto.randomUUID(),
35
+ messages: messages.map((message) => ({
36
+ id: message.id,
37
+ role: message.role === "user" ? "user" : "assistant",
38
+ text: message.content
39
+ .filter((part) => part.type === "text")
40
+ .map((part) => part.text)
41
+ .join(""),
42
+ metadata: message.metadata.custom,
43
+ files: Array.isArray(message.metadata.custom.superxdFiles)
44
+ ? message.metadata.custom.superxdFiles.map(parseChatFile)
45
+ : undefined,
46
+ })),
47
+ },
48
+ abortSignal,
49
+ )) {
50
+ if (event.type === "error") throw new Error("Response failed");
51
+ if (event.type === "finish") return;
52
+ if (event.type === "metadata") metadata = event.metadata;
53
+ else text = event.type === "replace-text" ? event.text : text + event.text;
54
+ if (text.length > 100_000) throw new Error("Response too large");
55
+ yield { content: [{ type: "text", text }], metadata: { custom: { ...metadata } } };
56
+ }
57
+ if (!abortSignal.aborted) throw new Error("Response interrupted");
58
+ } catch (error) {
59
+ if (error instanceof ChatInterrupted)
60
+ throw new Error("Response interrupted", { cause: error });
61
+ throw error;
62
+ }
63
+ },
64
+ }),
65
+ [adapter],
66
+ );
67
+ const runtime = useLocalRuntime(bridge);
68
+ return (
69
+ <AssistantRuntimeProvider runtime={runtime}>
70
+ <Bridge adapter={adapter}>{children}</Bridge>
71
+ </AssistantRuntimeProvider>
72
+ );
73
+ }
74
+
75
+ function Bridge({
76
+ adapter,
77
+ children,
78
+ }: {
79
+ adapter: ChatAdapter;
80
+ children: (chat: Projection) => ReactNode;
81
+ }) {
82
+ const aui = useAui();
83
+ const thread = aui.thread;
84
+ const state = useAuiState((s) => s.thread);
85
+ const last = state.messages.at(-1);
86
+ const interrupted =
87
+ last?.role === "assistant" &&
88
+ last.status.type === "incomplete" &&
89
+ typeof last.status.error === "object" &&
90
+ last.status.error !== null &&
91
+ "message" in last.status.error &&
92
+ last.status.error.message === "Response interrupted";
93
+ const status: ChatSnapshot["status"] = state.isRunning
94
+ ? last?.content.some((part) => part.type === "text" && part.text)
95
+ ? "streaming"
96
+ : "waiting"
97
+ : last?.role === "assistant"
98
+ ? last.status.type === "incomplete"
99
+ ? last.status.reason === "cancelled"
100
+ ? "cancelled"
101
+ : interrupted
102
+ ? "interrupted"
103
+ : "error"
104
+ : "complete"
105
+ : "idle";
106
+ return children({
107
+ snapshot: {
108
+ status,
109
+ capabilities: adapter.capabilities,
110
+ branches: Object.fromEntries(
111
+ state.messages.flatMap((message) => {
112
+ const branch = thread.message({ id: message.id }).getState();
113
+ return branch.branchCount > 1
114
+ ? [[message.id, { index: branch.branchNumber - 1, count: branch.branchCount }]]
115
+ : [];
116
+ }),
117
+ ),
118
+ messages: state.messages.map((message) => ({
119
+ id: message.id,
120
+ role: message.role === "user" ? "user" : "assistant",
121
+ text: message.content
122
+ .filter((part) => part.type === "text")
123
+ .map((part) => part.text)
124
+ .join(""),
125
+ metadata: message.metadata.custom,
126
+ files: Array.isArray(message.metadata.custom.superxdFiles)
127
+ ? message.metadata.custom.superxdFiles.map(parseChatFile)
128
+ : undefined,
129
+ })),
130
+ },
131
+ actions: {
132
+ send(text, files = []) {
133
+ if (
134
+ thread.getState().isRunning ||
135
+ thread.getState().messages.length >= 98 ||
136
+ !adapter.capabilities.send ||
137
+ (!text.trim() && !files.length) ||
138
+ (files.length > 0 && !adapter.capabilities.attachments) ||
139
+ files.length > 4 ||
140
+ text.length > 20_000
141
+ )
142
+ return false;
143
+ let validated;
144
+ try {
145
+ validated = files.map(parseChatFile);
146
+ } catch {
147
+ return false;
148
+ }
149
+ if (!validated.length) thread.append(text);
150
+ else
151
+ thread.append({
152
+ role: "user",
153
+ content: [
154
+ ...(text ? [{ type: "text" as const, text }] : []),
155
+ ...validated.map((file) => ({
156
+ type: "file" as const,
157
+ data: file.url,
158
+ mimeType: file.mediaType,
159
+ filename: file.name,
160
+ sourceType: "url" as const,
161
+ })),
162
+ ],
163
+ metadata: { custom: { superxdFiles: validated } },
164
+ });
165
+ return true;
166
+ },
167
+ cancel() {
168
+ if (adapter.capabilities.cancel) thread.cancelRun();
169
+ },
170
+ retry() {
171
+ if (
172
+ thread.getState().isRunning ||
173
+ !adapter.capabilities.retry ||
174
+ !["error", "interrupted", "cancelled"].includes(status)
175
+ )
176
+ return false;
177
+ const parent = [...state.messages].reverse().find((message) => message.role === "user");
178
+ if (!parent) return false;
179
+ thread.startRun({ parentId: parent.id });
180
+ return true;
181
+ },
182
+ edit(messageId, text) {
183
+ if (
184
+ thread.getState().isRunning ||
185
+ !thread.getState().messages.some((message) => message.id === messageId)
186
+ )
187
+ return false;
188
+ const source = thread.message({ id: messageId }).getState();
189
+ const files = Array.isArray(source.metadata.custom.superxdFiles)
190
+ ? source.metadata.custom.superxdFiles.map(parseChatFile)
191
+ : [];
192
+ const previous = source.content
193
+ .filter((part) => part.type === "text")
194
+ .map((part) => part.text)
195
+ .join("");
196
+ if (
197
+ thread.getState().isRunning ||
198
+ !adapter.capabilities.send ||
199
+ !adapter.capabilities.edit ||
200
+ source.role !== "user" ||
201
+ !state.messages.some((message) => message.id === messageId) ||
202
+ text.length > 20_000 ||
203
+ (!text.trim() && !files.length) ||
204
+ text === previous ||
205
+ source.branchCount >= 20
206
+ )
207
+ return false;
208
+ thread.append({
209
+ role: "user",
210
+ parentId: source.parentId,
211
+ sourceId: source.id,
212
+ content: [
213
+ ...(text ? [{ type: "text" as const, text }] : []),
214
+ ...files.map((file) => ({
215
+ type: "file" as const,
216
+ data: file.url,
217
+ mimeType: file.mediaType,
218
+ filename: file.name,
219
+ sourceType: "url" as const,
220
+ })),
221
+ ],
222
+ metadata: { custom: { ...source.metadata.custom, superxdFiles: files } },
223
+ });
224
+ return true;
225
+ },
226
+ regenerate(messageId) {
227
+ if (
228
+ thread.getState().isRunning ||
229
+ !thread.getState().messages.some((message) => message.id === messageId)
230
+ )
231
+ return false;
232
+ const source = thread.message({ id: messageId }).getState();
233
+ if (
234
+ thread.getState().isRunning ||
235
+ !adapter.capabilities.send ||
236
+ !adapter.capabilities.regenerate ||
237
+ source.role !== "assistant" ||
238
+ source.branchCount >= 20 ||
239
+ !state.messages.some((message) => message.id === messageId)
240
+ )
241
+ return false;
242
+ thread.message({ id: messageId }).reload();
243
+ return true;
244
+ },
245
+ selectBranch(messageId, direction) {
246
+ if (
247
+ thread.getState().isRunning ||
248
+ !thread.getState().messages.some((message) => message.id === messageId)
249
+ )
250
+ return false;
251
+ const source = thread.message({ id: messageId }).getState();
252
+ if (
253
+ thread.getState().isRunning ||
254
+ !adapter.capabilities.branches ||
255
+ !state.messages.some((message) => message.id === messageId) ||
256
+ (direction === "previous"
257
+ ? source.branchNumber <= 1
258
+ : source.branchNumber >= source.branchCount)
259
+ )
260
+ return false;
261
+ thread.message({ id: messageId }).switchToBranch({ position: direction });
262
+ return true;
263
+ },
264
+ },
265
+ });
266
+ }
@@ -0,0 +1,163 @@
1
+ import { parseChatFile, type ChatFile } from "./index.js";
2
+
3
+ export interface UploadSource<Data> {
4
+ readonly id?: string;
5
+ readonly name: string;
6
+ readonly mediaType: string;
7
+ readonly size: number;
8
+ readonly data: Data;
9
+ }
10
+ export interface UploadAdapter<Data> {
11
+ upload(source: UploadSource<Data>, signal: AbortSignal): Promise<ChatFile>;
12
+ /** Release a removed draft file. Transferred transcript references must stay valid. */
13
+ release?(file: ChatFile): Promise<void>;
14
+ }
15
+ export interface UploadPolicy {
16
+ readonly maxFiles: number;
17
+ readonly maxBytes: number;
18
+ readonly mediaTypes: readonly string[];
19
+ }
20
+ export type AttachmentIssue = "type" | "size" | "count" | "failed";
21
+ export interface AttachmentItem {
22
+ readonly id: string;
23
+ readonly name: string;
24
+ readonly mediaType: string;
25
+ readonly size: number;
26
+ readonly status: "uploading" | "ready" | "error" | "cancelled" | "rejected";
27
+ readonly file?: ChatFile;
28
+ readonly issue?: AttachmentIssue;
29
+ }
30
+ export interface AttachmentSnapshot {
31
+ readonly items: readonly AttachmentItem[];
32
+ readonly issue?: AttachmentIssue;
33
+ }
34
+ /** Upload state is separate from transcript state; no browser assumptions or second chat owner. */
35
+ export class AttachmentController<Data> {
36
+ private snapshot: AttachmentSnapshot = { items: [] };
37
+ private readonly listeners = new Set<() => void>();
38
+ private readonly sources = new Map<string, UploadSource<Data>>();
39
+ private readonly active = new Map<string, AbortController>();
40
+ constructor(
41
+ private readonly adapter: UploadAdapter<Data>,
42
+ readonly policy: UploadPolicy,
43
+ private readonly id: () => string,
44
+ ) {}
45
+ getSnapshot = (): AttachmentSnapshot => this.snapshot;
46
+ subscribe = (listener: () => void) => {
47
+ this.listeners.add(listener);
48
+ return () => this.listeners.delete(listener);
49
+ };
50
+ private publish(items: readonly AttachmentItem[], issue?: AttachmentIssue) {
51
+ this.snapshot = { items, ...(issue ? { issue } : {}) };
52
+ for (const listener of this.listeners) listener();
53
+ }
54
+ add = (inputs: readonly UploadSource<Data>[]): void => {
55
+ let countIssue: AttachmentIssue | undefined;
56
+ for (const source of inputs) {
57
+ if (this.snapshot.items.length >= this.policy.maxFiles) {
58
+ countIssue = "count";
59
+ continue;
60
+ }
61
+ const id = this.id();
62
+ const issue = !this.policy.mediaTypes.includes(source.mediaType)
63
+ ? "type"
64
+ : !Number.isSafeInteger(source.size) ||
65
+ source.size < 1 ||
66
+ source.size > this.policy.maxBytes
67
+ ? "size"
68
+ : undefined;
69
+ const identified = { ...source, id };
70
+ this.sources.set(id, identified);
71
+ const item: AttachmentItem = {
72
+ id,
73
+ name: source.name,
74
+ mediaType: source.mediaType,
75
+ size: source.size,
76
+ status: issue ? "rejected" : "uploading",
77
+ ...(issue ? { issue } : {}),
78
+ };
79
+ this.publish([...this.snapshot.items, item]);
80
+ if (!issue) this.begin(id, identified);
81
+ }
82
+ if (countIssue) this.publish(this.snapshot.items, countIssue);
83
+ };
84
+ cancel = (id: string): void => {
85
+ if (!this.active.has(id)) return;
86
+ this.active.get(id)?.abort();
87
+ this.active.delete(id);
88
+ this.publish(
89
+ this.snapshot.items.map((item) => (item.id === id ? { ...item, status: "cancelled" } : item)),
90
+ );
91
+ };
92
+ remove = (id: string): void => {
93
+ const item = this.snapshot.items.find((item) => item.id === id);
94
+ this.active.get(id)?.abort();
95
+ this.active.delete(id);
96
+ this.sources.delete(id);
97
+ if (item?.file) void this.adapter.release?.(item.file).catch(() => {});
98
+ this.publish(this.snapshot.items.filter((item) => item.id !== id));
99
+ };
100
+ retry = (id: string): void => {
101
+ const item = this.snapshot.items.find((item) => item.id === id);
102
+ const source = this.sources.get(id);
103
+ if (!source || !item || !["error", "cancelled"].includes(item.status)) return;
104
+ this.publish(
105
+ this.snapshot.items.map((item) =>
106
+ item.id === id ? { ...item, status: "uploading", issue: undefined } : item,
107
+ ),
108
+ );
109
+ this.begin(id, source);
110
+ };
111
+ /** Call only after send accepts: files now belong to the transcript, not the draft. */
112
+ transfer = (): void => {
113
+ const ready = this.snapshot.items.filter((item) => item.status === "ready");
114
+ for (const item of ready) this.sources.delete(item.id);
115
+ this.publish(this.snapshot.items.filter((item) => item.status !== "ready"));
116
+ };
117
+ dispose = (): void => {
118
+ for (const controller of this.active.values()) controller.abort();
119
+ this.active.clear();
120
+ for (const item of this.snapshot.items)
121
+ if (item.file) void this.adapter.release?.(item.file).catch(() => {});
122
+ this.sources.clear();
123
+ this.snapshot = { items: [] };
124
+ this.listeners.clear();
125
+ };
126
+ private begin(id: string, source: UploadSource<Data>) {
127
+ const controller = new AbortController();
128
+ this.active.set(id, controller);
129
+ void Promise.resolve()
130
+ .then(() => {
131
+ if (controller.signal.aborted) throw new Error("Upload cancelled before start");
132
+ return this.adapter.upload(source, controller.signal);
133
+ })
134
+ .then((value) => {
135
+ const file = parseChatFile(value);
136
+ if (
137
+ file.name !== source.name ||
138
+ file.mediaType !== source.mediaType ||
139
+ file.size !== source.size
140
+ )
141
+ throw new Error("Upload reference mismatch");
142
+ if (this.active.get(id) !== controller || controller.signal.aborted) {
143
+ void this.adapter.release?.(file).catch(() => {});
144
+ return;
145
+ }
146
+ this.active.delete(id);
147
+ this.publish(
148
+ this.snapshot.items.map((item) =>
149
+ item.id === id ? { ...item, status: "ready", file } : item,
150
+ ),
151
+ );
152
+ })
153
+ .catch(() => {
154
+ if (this.active.get(id) !== controller) return;
155
+ this.active.delete(id);
156
+ this.publish(
157
+ this.snapshot.items.map((item) =>
158
+ item.id === id ? { ...item, status: "error", issue: "failed" } : item,
159
+ ),
160
+ );
161
+ });
162
+ }
163
+ }